Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cli-release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ jobs:
# Fast path: --version exits synchronously through commander, so it
# only catches early sync failures. Run it for parity with old CI.
"$BIN" --version
"$BIN" --smoke-login-primitives

# Slow path: keep the binary alive long enough for *async* startup
# failures (e.g. the Parser.init rejection that crashed the
Expand Down Expand Up @@ -338,6 +339,7 @@ jobs:

# Sync check — exits via commander before async tasks fire.
"$BIN" --version
"$BIN" --smoke-login-primitives

# Long-running check — gives async startup failures time to surface.
# This is the step that would have caught the post-OpenTUI-upgrade
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/freebuff-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
# startup failures (e.g. the Parser.init rejection from a broken
# tree-sitter wasm load).
cli/bin/freebuff --version
cli/bin/freebuff --smoke-login-primitives
# Run for a few seconds so unhandled rejections during module init
# have a chance to fire and trip earlyFatalHandler.
bun cli/scripts/smoke-binary.ts cli/bin/freebuff
Expand Down Expand Up @@ -178,6 +179,7 @@ jobs:
# startup failures (e.g. the Parser.init rejection from a broken
# tree-sitter wasm load).
./cli/bin/freebuff.exe --version
./cli/bin/freebuff.exe --smoke-login-primitives
# Run for several seconds so unhandled rejections during module
# init have time to fire — the freebuff 0.0.62 wasm regression
# surfaced through the *late* renderer-cleanup handler, after the
Expand Down
66 changes: 59 additions & 7 deletions cli/scripts/smoke-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
* binary, lets it run for a few seconds, then kills it and asserts the TUI
* actually rendered a known boot screen.
*
* The positive check matters more than the negative one: a "did the boot
* screen appear" assertion catches *any* startup failure — known fatals,
* Before the long-running UI check, this also invokes explicit compiled
* runtime smoke flags in the binary. Those cover startup-sensitive assets
* (tree-sitter) and non-UI runtime integrations (network, subprocesses,
* vendored native tools, filesystem IO).
*
* The positive boot check matters more than the negative one: a "did the
* boot screen appear" assertion catches *any* startup failure — known fatals,
* novel error messages, silent crashes, hangs, segfaults that produce no
* output. Negative pattern matches are kept only for clearer diagnostics
* when a known regression recurs.
Expand Down Expand Up @@ -81,9 +86,21 @@ const FATAL_PATTERNS = [
// the renderer is up).
const DEFAULT_RUN_SECONDS = 10

function runTreeSitterSmoke(binary: string): Promise<void> {
function runFlagSmoke({
binary,
flag,
label,
okPattern,
timeoutMs,
}: {
binary: string
flag: string
label: string
okPattern: RegExp
timeoutMs: number
}): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(binary, ['--smoke-tree-sitter'], {
const proc = spawn(binary, [flag], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
})
Expand All @@ -95,16 +112,36 @@ function runTreeSitterSmoke(binary: string): Promise<void> {
proc.stdout?.on('data', append)
proc.stderr?.on('data', append)

let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
proc.kill('SIGKILL')
}, timeoutMs)

proc.once('error', reject)
proc.once('exit', (code) => {
if (code === 0 && /tree-sitter smoke ok/.test(captured)) {
clearTimeout(timeout)

if (timedOut) {
reject(
new Error(
`${label} smoke timed out after ${timeoutMs}ms\n${captured.slice(
0,
8 * 1024,
)}`,
),
)
return
}

if (code === 0 && okPattern.test(captured)) {
resolve()
return
}

reject(
new Error(
`tree-sitter smoke failed with exit code ${code}\n${captured.slice(
`${label} smoke failed with exit code ${code}\n${captured.slice(
0,
8 * 1024,
)}`,
Expand Down Expand Up @@ -133,9 +170,24 @@ async function main(): Promise<void> {

console.log(`smoke-binary: spawning ${binary} for ${runSeconds}s…`)

await runTreeSitterSmoke(binary)
await runFlagSmoke({
binary,
flag: '--smoke-tree-sitter',
label: 'tree-sitter',
okPattern: /tree-sitter smoke ok/,
timeoutMs: 30_000,
})
console.log('smoke-binary: tree-sitter init OK.')

await runFlagSmoke({
binary,
flag: '--smoke-runtime-primitives',
label: 'runtime primitives',
okPattern: /runtime primitives smoke ok/,
timeoutMs: 90_000,
})
console.log('smoke-binary: runtime primitives OK.')

const proc = spawn(binary, [], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
Expand Down
76 changes: 76 additions & 0 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,82 @@ async function main(): Promise<void> {
}
}

// CI gate: `<binary> --smoke-login-primitives` checks the local pieces that
// must work before browser OAuth can complete. This is intentionally not a
// full OAuth flow: CI should not depend on a real GitHub account/browser
// round trip just to validate the compiled Windows executable.
if (process.argv.includes('--smoke-login-primitives')) {
try {
const [{ withTimeout }, fingerprint, { getWindowsOpenUrlCommand }] =
await Promise.all([
import('@codebuff/common/util/promise'),
import('./utils/fingerprint'),
import('./utils/open-url'),
])

let timeoutRejected = false
try {
await withTimeout(
new Promise<never>(() => {}),
50,
'login smoke expected timeout',
)
} catch (err) {
timeoutRejected =
err instanceof Error &&
err.message.includes('login smoke expected timeout')
}
if (!timeoutRejected) {
throw new Error('withTimeout did not reject a hanging promise')
}

const fingerprintId = await withTimeout(
fingerprint.calculateFingerprint(),
5_000,
'calculateFingerprint exceeded login smoke timeout',
)
const fingerprintType = fingerprint.getFingerprintType(fingerprintId)
if (fingerprintType === 'unknown') {
throw new Error(`Unexpected fingerprint type for ${fingerprintId}`)
}

if (process.platform === 'win32') {
const opener = getWindowsOpenUrlCommand('https://example.com')
if (
opener.command !== 'rundll32.exe' ||
opener.args[0] !== 'url.dll,FileProtocolHandler' ||
opener.args[1] !== 'https://example.com'
) {
throw new Error(
`Unexpected Windows URL opener: ${opener.command} ${opener.args.join(' ')}`,
)
}
}

console.log(`login primitives smoke ok (${fingerprintType})`)
process.exit(0)
} catch (err) {
console.error('login primitives smoke FAIL:', err)
process.exit(1)
}
}

// CI gate: `<binary> --smoke-runtime-primitives` exercises the highest-risk
// compiled runtime integrations without entering the TUI: disk IO, vendored
// native tools, subprocesses, and the real login-code HTTP endpoint.
if (process.argv.includes('--smoke-runtime-primitives')) {
try {
const { runRuntimePrimitivesSmoke } = await import(
'./smoke/runtime-primitives'
)
await runRuntimePrimitivesSmoke()
process.exit(0)
} catch (err) {
console.error('runtime primitives smoke FAIL:', err)
process.exit(1)
}
}

// Run OSC theme detection BEFORE anything else.
// This MUST happen before OpenTUI starts because OSC responses come through stdin,
// and OpenTUI also listens to stdin. Running detection here ensures stdin is clean.
Expand Down
Loading
Loading