diff --git a/docs/expected-repository-rules.md b/docs/expected-repository-rules.md index 302784a37..1671ec7bf 100644 --- a/docs/expected-repository-rules.md +++ b/docs/expected-repository-rules.md @@ -59,6 +59,10 @@ A repository that deviates from these rules is fixed manually (or by re-running - Plain Next.js apps run Next.js >= 16.3, so `wb start` passes no bundler flag to `next dev` (Turbopack is the default). Blitz apps pin Next.js 15, where the flagless `next dev` selects the webpack dev server that `withBlitz` requires, and wbfy keeps their `typescript` pin below 7 (Next.js 15's `next build` cannot use tsgo). - macOS and Linux only; Windows is unsupported. A project using `wb db restore` with Turso declares a `libsql:` or `turso:` `DATABASE_URL` and `DATABASE_AUTH_TOKEN`, and has `sqlite3` on `PATH`. +## Standalone GitHub workflows + +- Generated standalone test workflows capture and upload raw test logs only when the repository Actions variable `UPLOAD_TEST_LOG` is `true`, with 14-day artifact retention after success or failure. Enable this only for secret-free test output: artifact files do not receive GitHub's console secret masking. + ## GitHub-side conventions (WillBooster / WillBoosterLab repositories) - Squash-only merges with `PR_TITLE` messages, auto-merge enabled, and head-branch deletion on merge. diff --git a/packages/shared-lib-node/src/spawn.ts b/packages/shared-lib-node/src/spawn.ts index 0f74ca9e2..8a9f57ec0 100644 --- a/packages/shared-lib-node/src/spawn.ts +++ b/packages/shared-lib-node/src/spawn.ts @@ -30,6 +30,8 @@ export type SpawnAsyncOptions = ( | SpawnOptionsWithStdioTuple | SpawnOptions ) & { + /** Whether to retain stdout/stderr in the returned result; defaults to true. */ + collectOutput?: boolean; /** Input string to write to the spawned process's stdin */ input?: string; /** If true, stderr output will be merged into stdout */ @@ -86,20 +88,27 @@ export async function spawnAsync( let stderr = ''; const stdoutPrinter = createRealtimePrinter(process.stdout, options?.omitBlankLinesWhilePrinting); const stderrPrinter = createRealtimePrinter(process.stderr, options?.omitBlankLinesWhilePrinting); + const resumeStdout = (): void => { + proc.stdout?.resume(); + }; + const resumeStderr = (): void => { + proc.stderr?.resume(); + }; proc.stdout?.on('data', (data: string) => { - stdout += data; - if (options?.printingStdout) { - stdoutPrinter.write(data); + if (options?.collectOutput !== false) stdout += data; + if (options?.printingStdout && !stdoutPrinter.write(data)) { + proc.stdout?.pause(); + process.stdout.once('drain', resumeStdout); } }); proc.stderr?.on('data', (data: string) => { - if (options?.mergeOutAndError) { - stdout += data; - } else { - stderr += data; + if (options?.collectOutput !== false) { + if (options?.mergeOutAndError) stdout += data; + else stderr += data; } - if (options?.printingStderr) { - stderrPrinter.write(data); + if (options?.printingStderr && !stderrPrinter.write(data)) { + proc.stderr?.pause(); + process.stderr.once('drain', resumeStderr); } }); @@ -144,12 +153,18 @@ export async function spawnAsync( } } + const removeDrainHandlers = (): void => { + process.stdout.removeListener('drain', resumeStdout); + process.stderr.removeListener('drain', resumeStderr); + }; proc.on('error', (error) => { + removeDrainHandlers(); removeKillOnExitHandlers(); proc.removeAllListeners('close'); reject(error); }); proc.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + removeDrainHandlers(); removeKillOnExitHandlers(); stdoutPrinter.flush(); stderrPrinter.flush(); @@ -182,7 +197,7 @@ const ANSI_ESCAPE_CODE_REGEXP = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]* function createRealtimePrinter( stream: NodeJS.WriteStream, omitBlankLines = false -): { write: (data: string) => void; flush: () => void } { +): { write: (data: string) => boolean; flush: () => void } { if (!omitBlankLines) { return { write: (data) => stream.write(data), @@ -195,12 +210,12 @@ function createRealtimePrinter( write: (data) => { pending += data; const lines = pending.split(/\r?\n/); + let ready = true; pending = lines.pop() ?? ''; for (const line of lines) { - if (!isBlankLine(line)) { - stream.write(`${line}\n`); - } + if (!isBlankLine(line) && !stream.write(`${line}\n`)) ready = false; } + return ready; }, flush: () => { if (!isBlankLine(pending)) { diff --git a/packages/wb/README.md b/packages/wb/README.md index a1093a49c..13b49d12a 100644 --- a/packages/wb/README.md +++ b/packages/wb/README.md @@ -137,6 +137,10 @@ Output is saved as it arrives, before display filtering, to `.wb/verify.log` or `.wb/verify-full.log` in the verified project. Each command overwrites its previous log; `--dry-run` leaves logs untouched. +`wb test-on-ci` streams raw output and overwrites `.wb/test-ci.log` in the selected project on each run. Failed commands retain their exit status after output has been flushed; `--dry-run` preserves the previous log. Local logs are independent of workflow artifact uploads. + +For all three commands, a log-write failure falls back to printing subsequent raw output. If a log cannot be completed, the command reports the log error and fails an otherwise successful run; an existing nonzero command exit status is preserved. + ## Slidev checks `wb slidev-check` checks slide text with textlint, then checks rendered Slidev decks. diff --git a/packages/wb/src/commands/testOnCi.ts b/packages/wb/src/commands/testOnCi.ts index 9a13f735a..95bf46e21 100644 --- a/packages/wb/src/commands/testOnCi.ts +++ b/packages/wb/src/commands/testOnCi.ts @@ -4,12 +4,15 @@ import path from 'node:path'; import chalk from 'chalk'; import type { ArgumentsCamelCase, CommandModule, InferredOptionTypes } from 'yargs'; +import type { Project } from '../project.js'; import { findDescendantProjects } from '../project.js'; import { toDevNull } from '../scripts/builder.js'; import { dockerScripts } from '../scripts/dockerScripts.js'; import { selectScripts } from '../scripts/execution/selectScripts.js'; import { runWithSpawn, runWithSpawnInParallel } from '../scripts/run.js'; import type { sharedOptionsBuilder } from '../sharedOptionsBuilder.js'; +import { PackageCommandError } from '../utils/packageCommand.js'; +import { startVerificationOutput } from '../utils/verificationOutput.js'; import { promisePool } from '../utils/promisePool.js'; import { findTestStructureViolations, printTestStructureViolations } from '../utils/testStructure.js'; @@ -47,7 +50,25 @@ export async function testOnCi( process.exit(1); } - for (const project of projects.descendants) { + const reporter = argv.dryRun + ? undefined + : startVerificationOutput(path.join(projects.self.dirPath, '.wb', 'test-ci.log'), true); + try { + await runTests(projects.descendants, argv); + if (!process.exitCode) reporter?.succeed(); + } catch (error) { + if (!(error instanceof PackageCommandError)) console.error(error); + process.exitCode = error instanceof PackageCommandError ? error.exitCode : 1; + } finally { + await reporter?.finish(Number(process.exitCode ?? 0)); + } +} + +async function runTests( + projects: Project[], + argv: ArgumentsCamelCase> +): Promise { + for (const project of projects) { project.env.CI ||= '1'; // Overwrite, not ||=: project.env already carries the dotenv-derived value. project.env.WB_ENV = process.env.WB_ENV; @@ -65,21 +86,21 @@ export async function testOnCi( const hasDockerfile = project.hasDockerfile; if (hasDockerfile) { - await runWithSpawnInParallel(dockerScripts.stopAll(), project, argv); + await runCiStep(dockerScripts.stopAll(), project, argv); } const defaultUnitTargets = getDefaultUnitTargets(project); if (defaultUnitTargets !== false) { // CI mode disallows `only` to avoid including debug tests const unitArgv = { ...argv, targets: defaultUnitTargets }; - await runWithSpawnInParallel(scripts.testUnit(project, unitArgv).replaceAll(' --allowOnly', ''), project, argv); + await runCiStep(scripts.testUnit(project, unitArgv).replaceAll(' --allowOnly', ''), project, argv); } if (fs.existsSync(path.join(project.dirPath, 'test', 'e2e'))) { // Confirm dev server startup for consistency across projects with E2E tests. - await runWithSpawnInParallel(await scripts.testStart(project, argv), project, argv); + await runCiStep(await scripts.testStart(project, argv), project, argv); await promisePool.promiseAll(); if (hasDockerfile) { project.env.WB_DOCKER ||= '1'; - await runWithSpawn(`${scripts.buildDocker(project, 'test')}${toDevNull(argv)}`, project, argv); + await runCiStep(`${scripts.buildDocker(project, 'test')}${toDevNull(argv)}`, project, argv); } const script = hasDockerfile ? await scripts.testE2EDocker(project, argv, {}) @@ -99,8 +120,17 @@ export async function testOnCi( process.exitCode = e2eExitCode; } if (hasDockerfile) { - await runWithSpawn(dockerScripts.stop(project), project, argv); + await runCiStep(dockerScripts.stop(project), project, argv); } } } } + +async function runCiStep( + script: string, + project: Project, + argv: ArgumentsCamelCase> +): Promise { + const exitCode = await runWithSpawnInParallel(script, project, argv, { exitIfFailed: false }); + if (exitCode !== 0) throw new PackageCommandError(exitCode); +} diff --git a/packages/wb/src/scripts/run.ts b/packages/wb/src/scripts/run.ts index 473164c25..b5e5b3b08 100644 --- a/packages/wb/src/scripts/run.ts +++ b/packages/wb/src/scripts/run.ts @@ -62,9 +62,13 @@ export async function runWithSpawn( : undefined; const ret = await spawnAsync(normalizedScript.runnable, undefined, { cwd: project.dirPath, - env: configureEnv(project.env, { ...opts, preserveColor: opts.preserveColor ?? (argv.silent ? true : undefined) }), + env: configureEnv(project.env, { + ...opts, + preserveColor: opts.preserveColor ?? (captureOutput || argv.silent ? true : undefined), + }), + collectOutput: !captureOutput, shell: true, - stdio: captureOutput || argv.silent ? 'pipe' : 'inherit', + stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : argv.silent ? 'pipe' : 'inherit', timeout: opts.timeout, mergeOutAndError: shouldProcessSilentOutput, killOnExit: true, @@ -118,8 +122,9 @@ export function runWithSpawnInParallel( const ret = await spawnAsync(normalizedScript.runnable, undefined, { cwd: project.dirPath, env: configureEnv(project.env, { ...opts, preserveColor: opts.preserveColor ?? true }), + collectOutput: !captureOutput, shell: true, - stdio: 'pipe', + stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : 'pipe', timeout: opts.timeout, mergeOutAndError: true, killOnExit: true, diff --git a/packages/wb/src/utils/verificationOutput.ts b/packages/wb/src/utils/verificationOutput.ts index 3f3f54936..08ee32980 100644 --- a/packages/wb/src/utils/verificationOutput.ts +++ b/packages/wb/src/utils/verificationOutput.ts @@ -10,8 +10,11 @@ export function isCapturingVerificationOutput(): boolean { return capturingVerificationOutput; } -/** Saves output as it arrives; successful verification exposes only its final recap. */ -export function startVerificationOutput(logPath: string): { +/** Saves output as it arrives, optionally streaming it instead of showing only a verification recap. */ +export function startVerificationOutput( + logPath: string, + streamOutput = false +): { startStep: (name?: string) => void; succeed: () => void; finish: (exitCode: number) => Promise; @@ -23,6 +26,7 @@ export function startVerificationOutput(logPath: string): { const originalConsole = globalThis.console; stdoutWrite(`Full log: ${logPath}\n`); let logSize = 0; + let logError: Error | undefined; let stepStart = 0; let stepName: string | undefined; let succeeded = false; @@ -35,9 +39,21 @@ export function startVerificationOutput(logPath: string): { typeof chunk === 'string' ? Buffer.from(chunk, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8') : chunk; - logSize += fs.writeSync(logFile, buffer); + if (!logError) { + try { + let offset = 0; + while (offset < buffer.length) { + const written = fs.writeSync(logFile, buffer, offset, buffer.length - offset); + if (written === 0) throw new Error('Log write made no progress'); + offset += written; + logSize += written; + } + } catch (error) { + logError = error instanceof Error ? error : new Error('Unknown log write error'); + } + } const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - if (succeeded) return original.call(stream, buffer, undefined, done); + if (succeeded || streamOutput || logError) return original.call(stream, buffer, undefined, done); if (done) queueMicrotask(done); return true; }) as typeof original; @@ -59,16 +75,33 @@ export function startVerificationOutput(logPath: string): { process.stderr.write = stderrWrite; globalThis.console = originalConsole; process.removeListener('exit', onExit); - const tail = succeeded ? '' : readFailureTail(logFile, stepStart, logSize); - fs.closeSync(logFile); - const message = `${succeeded ? 'Full log' : 'Verification failed. Full log'}: ${logPath}\n`; - fs.appendFileSync(logPath, message); - const output = succeeded - ? message - : `Failed step: ${stepName ?? 'verification setup'} (exit code ${exitCode})\n${tail}${message}`; - await new Promise((resolve, reject) => { - stdoutWrite(output, (error) => (error ? reject(error) : resolve())); - }); + let tail = ''; + const message = `${succeeded || streamOutput ? 'Full log' : 'Verification failed. Full log'}: ${logPath}\n`; + try { + try { + tail = succeeded || streamOutput ? '' : readFailureTail(logFile, stepStart, logSize); + } finally { + fs.closeSync(logFile); + } + if (!logError) fs.appendFileSync(logPath, message); + } catch (error) { + logError ??= error instanceof Error ? error : new Error('Unknown log I/O error'); + } + if (logError && !exitCode) process.exitCode = 1; + const output = + succeeded || streamOutput + ? message + : `Failed step: ${stepName ?? 'verification setup'} (exit code ${exitCode})\n${tail}${message}`; + await Promise.all([ + new Promise((resolve, reject) => { + stdoutWrite(`${output}${logError ? `Log incomplete: ${String(logError)}\n` : ''}`, (error) => + error ? reject(error) : resolve() + ); + }), + new Promise((resolve, reject) => { + stderrWrite('', (error) => (error ? reject(error) : resolve())); + }), + ]); }; // An unexpected process.exit() still closes the saved log. Normal failures await the flush. const onExit = (exitCode: number): void => { diff --git a/packages/wb/test/unit/verifyOutput.test.ts b/packages/wb/test/unit/verifyOutput.test.ts index 7cb8a383b..4149df72c 100644 --- a/packages/wb/test/unit/verifyOutput.test.ts +++ b/packages/wb/test/unit/verifyOutput.test.ts @@ -1,6 +1,7 @@ import { spawn, spawnSync, type SpawnSyncReturns } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { setImmediate } from 'node:timers/promises'; import { stripVTControlCharacters } from 'node:util'; import { afterEach, beforeAll, expect, it } from 'vitest'; @@ -152,6 +153,133 @@ it('preserves the previous log during dry-run and keeps standalone tests verbose expect(result.stdout).toContain('RAW_TEST_STDOUT'); }); +it.each([0, 7])('saves and flushes complete CI output with exit code %s', async (exitCode) => { + const dir = await createFixture(); + const logPath = path.join(dir, '.wb/test-ci.log'); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.writeFile(logPath, 'PREVIOUS_RUN'); + const dryRun = runCli(dir, ['test-on-ci', '--dry-run']); + expect(dryRun.status, dryRun.stderr).toBe(0); + expect(await fs.readFile(logPath, 'utf8')).toBe('PREVIOUS_RUN'); + await fs.writeFile( + path.join(dir, 'test/unit/example.test.ts'), + `import fs from 'node:fs'; +import { test } from 'bun:test'; +test('large output', () => { + fs.writeFileSync(1, 'CI_STDOUT_α😀\\n'.repeat(20_000)); + fs.writeFileSync(2, 'CI_STDERR_α😀\\n'.repeat(20_000)); + ${exitCode ? `process.exit(${exitCode});` : ''} +});` + ); + const result = runCli(dir, ['test-on-ci']); + expect(result.status, result.stderr).toBe(exitCode); + const log = await fs.readFile(logPath, 'utf8'); + for (const output of [log, result.stdout + result.stderr]) { + expect(output.match(/CI_STDOUT_α😀/g)).toHaveLength(20_000); + expect(output.match(/CI_STDERR_α😀/g)).toHaveLength(20_000); + expect(output).not.toContain('PREVIOUS_RUN'); + } + expect(result.stdout).toContain(logPath); +}); + +it('preserves stdin EOF for CI E2E commands while capturing output', async () => { + const dir = await createFixture(); + await fs.mkdir(path.join(dir, 'test/e2e')); + await fs.writeFile( + path.join(dir, 'test/e2e/input.test.ts'), + `import fs from 'node:fs'; +import { test, expect } from 'bun:test'; +test('stdin', () => { + expect(fs.readFileSync(0).length).toBe(0); + console.log('E2E_STDIN_CLOSED'); +});` + ); + const result = runCli(dir, ['test-on-ci']); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain('E2E_STDIN_CLOSED'); + expect(await fs.readFile(path.join(dir, '.wb/test-ci.log'), 'utf8')).toContain('E2E_STDIN_CLOSED'); +}); + +it.each([0, 7])('reports a full log device without interrupting the command with exit code %s', async (exitCode) => { + const dir = await createFixture(); + await fs.writeFile( + path.join(dir, 'test/unit/example.test.ts'), + `import fs from 'node:fs'; +import { test } from 'bun:test'; +test('output', () => { + fs.writeFileSync(1, 'DISK_LIMIT_OUTPUT\\n'.repeat(20_000)); + ${exitCode ? `process.exit(${exitCode});` : ''} +});` + ); + const result = spawnSync('bash', ['-c', 'trap \'\' XFSZ; ulimit -f 1; exec node "$1" test-on-ci', 'bash', cliPath], { + cwd: dir, + encoding: 'utf8', + maxBuffer: 2 * 1024 * 1024, + timeout: 30_000, + }); + expect(result.status, result.stderr).toBe(exitCode || 1); + expect(result.stdout.split('DISK_LIMIT_OUTPUT')).toHaveLength(20_001); + expect(result.stdout).toContain('Log incomplete:'); +}); + +it('streams failing verification output when its log is full', async () => { + const dir = await createFixture(); + await fs.writeFile(path.join(dir, 'generate.ts'), "process.stdout.write('FILL_LOG'.repeat(8192));"); + await fs.writeFile( + path.join(dir, 'test/unit/example.test.ts'), + "import { test, expect } from 'bun:test'; test('failure', () => { expect(false, 'ASSERTION_AFTER_LOG_FAILURE').toBe(true); });" + ); + const result = spawnSync( + 'bash', + ['-c', 'trap \'\' XFSZ; ulimit -f 1; exec node "$1" verify --full', 'bash', cliPath], + { + cwd: dir, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 2 * 1024 * 1024, + } + ); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stdout + result.stderr).toContain('ASSERTION_AFTER_LOG_FAILURE'); + expect(result.stdout).toContain('Log incomplete:'); +}); + +it('streams CI output larger than the wrapper heap without retaining it in memory', async () => { + const dir = await createFixture(); + await fs.mkdir(path.join(dir, 'test/e2e')); + await fs.writeFile( + path.join(dir, 'test/e2e/large.test.ts'), + `import fs from 'node:fs'; +import { test } from 'bun:test'; +test('large stream', () => { + const chunk = 'x'.repeat(1024 * 1024); + for (let i = 0; i < 160; i++) fs.writeFileSync(1, chunk); +}, 30_000);` + ); + const child = spawn('node', ['--max-old-space-size=96', cliPath, 'test-on-ci'], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + let bytes = 0; + for await (const chunk of child.stdout) { + bytes += (chunk as Buffer).length; + await setImmediate(); + } + expect(await exited, stderr).toBe(0); + expect(bytes).toBeGreaterThanOrEqual(160 * 1024 * 1024); + const log = await fs.stat(path.join(dir, '.wb/test-ci.log')); + expect(log.size).toBeGreaterThanOrEqual(160 * 1024 * 1024); +}); + async function createFixture(): Promise { const tmp = path.resolve('.tmp'); await fs.mkdir(tmp, { recursive: true }); @@ -178,5 +306,10 @@ async function createFixture(): Promise { } function runCli(dir: string, args: string[]): SpawnSyncReturns { - return spawnSync('node', [cliPath, ...args], { cwd: dir, encoding: 'utf8', timeout: 30_000 }); + return spawnSync('node', [cliPath, ...args], { + cwd: dir, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); } diff --git a/packages/wbfy/README.md b/packages/wbfy/README.md index 2ae8bbe43..e050847d2 100644 --- a/packages/wbfy/README.md +++ b/packages/wbfy/README.md @@ -18,3 +18,5 @@ This tool must keep idempotency, i.e., it always yields the same result when a u 1. `bunx @willbooster/wbfy ` 2. `bunx @willbooster/wbfy generate-user-agent-configs` to overwrite your user-level agent instruction files (`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`, `~/.gemini/GEMINI.md`) with the organization's fixed content and merge the organization's settings (disabling Claude Code's commit/PR attribution and both agents' auto memory) into `~/.claude/settings.json` and `~/.gemini/settings.json` + +Generated standalone test workflows stream output. Log capture and upload are enabled together. Set the repository Actions variable `UPLOAD_TEST_LOG` to `true` to save and upload a log after success or failure with 14-day retention. Enable this only for secret-free test output: artifact files do not receive GitHub console secret masking. Artifact names include the OS, job check-run ID, and run attempt so each job and rerun retains its own log. diff --git a/packages/wbfy/src/generators/selfContainedWorkflow.ts b/packages/wbfy/src/generators/selfContainedWorkflow.ts index 5a7a27886..f5c650306 100644 --- a/packages/wbfy/src/generators/selfContainedWorkflow.ts +++ b/packages/wbfy/src/generators/selfContainedWorkflow.ts @@ -190,7 +190,33 @@ function buildTestWorkflow(config: PackageConfig, allPackageConfigs: PackageConf : []), ...(hasTypecheck ? [{ run: 'bun run typecheck', ...fnoxEnv }] : []), { run: 'bun run lint', ...fnoxEnv }, - { run: 'bun run test/ci', ...fnoxEnv }, + { + name: 'Test', + id: 'test', + run: `if [[ "$UPLOAD_TEST_LOG" != "true" ]]; then + exec bun run test/ci +fi +log_dir=$(mktemp -d "$RUNNER_TEMP/test-output.XXXXXX") +echo "log_path=$log_dir/test.log" >> "$GITHUB_OUTPUT" +# Keep draining test output when the log reaches a file-size limit. +set +e +bun run test/ci 2>&1 | (trap '' XFSZ; tee "$log_dir/test.log") +test_status=("\${PIPESTATUS[@]}") +if (( test_status[0] != 0 )); then exit "\${test_status[0]}"; fi +exit "\${test_status[1]}"`, + env: { ...fnoxEnv.env, UPLOAD_TEST_LOG: '${{ vars.UPLOAD_TEST_LOG }}' }, + }, + { + name: 'Upload test log', + if: "${{ always() && vars.UPLOAD_TEST_LOG == 'true' && steps.test.outputs.log_path != '' }}", + uses: uploadArtifactAction, + with: { + name: 'test-output-${{ runner.os }}-${{ job.check_run_id }}-${{ github.run_attempt }}', + path: '${{ steps.test.outputs.log_path }}', + 'retention-days': 14, + 'if-no-files-found': 'error', + }, + }, ...(playwrightDirPaths.length > 0 ? [ { diff --git a/packages/wbfy/test/unit/selfContainedWorkflow.test.ts b/packages/wbfy/test/unit/selfContainedWorkflow.test.ts index 1cadbff5f..e09911535 100644 --- a/packages/wbfy/test/unit/selfContainedWorkflow.test.ts +++ b/packages/wbfy/test/unit/selfContainedWorkflow.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -46,7 +47,6 @@ test('generates self-contained test and semantic-pr workflows without reusable-w expect(testContent).not.toContain('reusable-workflows'); const testWorkflow = YAML.parse(testContent) as ParsedWorkflow; const runCommands = testWorkflow.jobs.test?.steps.map((step) => step.run).filter(Boolean); - expect(runCommands).toContain('bun run test/ci'); // No TypeScript and no Playwright in this repository. expect(runCommands).not.toContain('bun run typecheck'); expect(testContent).not.toContain('playwright'); @@ -82,7 +82,7 @@ test('includes typecheck, Playwright caching and step-scoped FNOX_AGE_KEY when t // Step-scoped, not job-wide: `bun install` must not see the age identity. const installStep = steps.find((step) => step.name === 'Install dependencies'); expect(installStep?.env?.FNOX_AGE_KEY).toBeUndefined(); - const testStep = steps.find((step) => step.run === 'bun run test/ci'); + const testStep = steps.find((step) => step.name === 'Test'); expect(testStep?.env?.FNOX_AGE_KEY).toBe('${{ secrets.FNOX_AGE_KEY }}'); }); }); @@ -120,11 +120,85 @@ test('installs Playwright browsers from the declaring workspace package in a mon expect(cacheStep?.with?.key).toBe( 'playwright-${{ runner.os }}-${{ steps.playwright-version-0.outputs.version }}-${{ steps.playwright-version-1.outputs.version }}' ); - const uploadStep = steps.find((step) => step.uses?.startsWith('actions/upload-artifact@')); + const uploadStep = steps.find((step) => step.name === 'Upload test results'); expect(uploadStep?.with?.path).toBe('packages/app/test-results\npackages/web/test-results'); }); }); +test('generated test step preserves full logs and failing exit codes', async () => { + await withTempRepo(async (dirPath) => { + await generateSelfContainedWorkflows(createConfig({ dirPath, isRoot: true, isWillBoosterRepo: false })); + await promisePool.promiseAll(); + const workflow = YAML.parse( + await fs.readFile(path.join(dirPath, '.github/workflows/test.yml'), 'utf8') + ) as ParsedWorkflow; + const script = workflow.jobs.test!.steps.find((step) => step.name === 'Test')!.run!; + await fs.writeFile( + path.join(dirPath, 'emit.js'), + String.raw`require('node:fs').writeFileSync(1, 'stdout-evidence\n'.repeat(50_000)); require('node:fs').writeFileSync(2, 'stderr-evidence\n'.repeat(50_000)); process.exit(Number(process.argv[2]));` + ); + for (const exitCode of [0, 7]) { + await fs.writeFile( + path.join(dirPath, 'package.json'), + JSON.stringify({ scripts: { 'test/ci': `node emit.js ${exitCode}` } }) + ); + const outputPath = path.join(dirPath, `outputs-${exitCode}`); + const result = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { + cwd: dirPath, + env: { ...process.env, UPLOAD_TEST_LOG: 'true', RUNNER_TEMP: dirPath, GITHUB_OUTPUT: outputPath }, + encoding: 'utf8', + maxBuffer: 2 * 1024 * 1024, + }); + expect(result.status, result.stderr).toBe(exitCode); + const outputs = await fs.readFile(outputPath, 'utf8'); + const logPath = outputs.match(/^log_path=(.+)$/m)![1]!; + const log = await fs.readFile(logPath, 'utf8'); + for (const marker of ['stdout-evidence', 'stderr-evidence']) { + expect(log.split(marker)).toHaveLength(50_001); + expect(result.stdout.split(marker)).toHaveLength(50_001); + } + } + for (const exitCode of [0, 7]) { + await fs.writeFile( + path.join(dirPath, 'package.json'), + JSON.stringify({ scripts: { 'test/ci': `node emit.js ${exitCode}` } }) + ); + const limited = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', `ulimit -f 1\n${script}`], { + cwd: dirPath, + env: { + ...process.env, + UPLOAD_TEST_LOG: 'true', + RUNNER_TEMP: dirPath, + GITHUB_OUTPUT: path.join(dirPath, `limited-outputs-${exitCode}`), + }, + encoding: 'utf8', + maxBuffer: 3 * 1024 * 1024, + }); + expect(limited.status).toBe(exitCode || 1); + expect(limited.stdout.split('stdout-evidence')).toHaveLength(50_001); + expect(limited.stderr).toMatch(/File.*(size|limit|large)/i); + } + await fs.writeFile( + path.join(dirPath, 'package.json'), + JSON.stringify({ scripts: { 'test/ci': "printf '%4096s' x" } }) + ); + const directOutputs = path.join(dirPath, 'direct-outputs'); + const direct = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { + cwd: dirPath, + env: { + ...process.env, + UPLOAD_TEST_LOG: 'false', + RUNNER_TEMP: path.join(dirPath, 'missing'), + GITHUB_OUTPUT: directOutputs, + }, + encoding: 'utf8', + }); + expect(direct.status, direct.stderr).toBe(0); + expect(direct.stdout).toHaveLength(4096); + expect(await Bun.file(directOutputs).exists()).toBe(false); + }); +}); + test('semantic-pr workflow grants the permissions the action needs', async () => { await withTempRepo(async (dirPath) => { const config = createConfig({