Skip to content
Merged
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
127 changes: 126 additions & 1 deletion src/commands/test.run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* sleep injection is wired through `TestDeps.sleep` to avoid real delays.
*/

import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Command } from 'commander';
Expand Down Expand Up @@ -2725,6 +2725,7 @@ describe('runTestRunAll — batch fresh run', () => {
fetchImpl,
stdout: line => stdoutLines.push(line),
stderr: () => undefined,
env: {} as NodeJS.ProcessEnv,
sleep: instantSleep,
},
);
Expand Down Expand Up @@ -3458,6 +3459,7 @@ describe('[B-E2E-01] runTestRunAll --wait: non-passed runs must exit 1 (regressi
fetchImpl,
stdout: line => stdoutLines.push(line),
stderr: () => undefined,
env: {} as NodeJS.ProcessEnv,
sleep: instantSleep,
},
);
Expand Down Expand Up @@ -3866,6 +3868,7 @@ describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out p
fetchImpl,
stdout: line => stdoutLines.push(line),
stderr: () => undefined,
env: {} as NodeJS.ProcessEnv,
sleep: instantSleep,
},
).catch(e => e);
Expand Down Expand Up @@ -3957,3 +3960,125 @@ describe('runTestRun --wait — InterruptError graceful detach (DEV-331)', () =>
expect(stderrBlock).toContain('testsprite test wait run_abc');
});
});

describe('gh-output integration on run --all --wait (issue #99 reshape)', () => {
function makeTerminalRun(runId: string, testId: string, status: string): RunResponse {
return {
runId,
testId,
projectId: 'project_be',
userId: 'user_1',
status: status as RunResponse['status'],
source: 'cli',
createdAt: '2026-06-09T11:00:00.000Z',
startedAt: '2026-06-09T11:00:01.000Z',
finishedAt: '2026-06-09T11:00:30.000Z',
codeVersion: 'v1',
targetUrl: 'https://api.example.com',
createdFrom: 'cli',
failedStepIndex: null,
failureKind: null,
error: null,
videoUrl: null,
stepSummary: {
total: 3,
completed: 3,
passedCount: status === 'passed' ? 3 : 0,
failedCount: 0,
},
};
}

function mixedHarness() {
const { credentialsPath } = makeCreds();
const mixedBatch: BatchRunFreshResponse = {
accepted: [
{ testId: 'test_p', runId: 'run_p', enqueuedAt: '2026-06-09T11:00:00.000Z' },
{ testId: 'test_f', runId: 'run_f', enqueuedAt: '2026-06-09T11:00:02.000Z' },
],
conflicts: [],
deferred: [],
skippedFrontend: [],
skippedIntegration: [],
};
const fetchImpl = makeFetch((url, init) => {
if ((init.method ?? 'GET') === 'POST') return { body: mixedBatch };
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
if (runId === 'run_p') return { body: makeTerminalRun('run_p', 'test_p', 'passed') };
if (runId === 'run_f') return { body: makeTerminalRun('run_f', 'test_f', 'failed') };
return errorBody('NOT_FOUND');
});
return { credentialsPath, fetchImpl };
}

it('under Actions with --output json: stdout stays parseable JSON, ::error:: goes to stderr', async () => {
const { credentialsPath, fetchImpl } = mixedHarness();
const stdoutLines: string[] = [];
const stderrLines: string[] = [];
const err = await runTestRunAll(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'project_be',
wait: true,
timeoutSeconds: 60,
maxConcurrency: 5,
},
{
credentialsPath,
fetchImpl,
stdout: line => stdoutLines.push(line),
stderr: line => stderrLines.push(line),
env: { GITHUB_ACTIONS: 'true' } as NodeJS.ProcessEnv,
sleep: instantSleep,
},
).catch(e => e);
expect(err).toMatchObject({ exitCode: 1 });
// The documented machine envelope must remain parseable as-is.
const payload = JSON.parse(stdoutLines.join('\n')) as { accepted?: unknown[] };
expect(Array.isArray(payload.accepted)).toBe(true);
expect(stdoutLines.some(line => line.startsWith('::error'))).toBe(false);
const annotations = stderrLines.filter(line => line.startsWith('::error'));
expect(annotations).toHaveLength(1);
expect(annotations[0]).toContain('test_f');
});

it('--gh-output --summary-file writes the reduced artifact even though the gate exits 1', async () => {
const { credentialsPath, fetchImpl } = mixedHarness();
const dir = mkdtempSync(join(tmpdir(), 'cli-gh-output-'));
const summaryFile = join(dir, 'summary.json');
const stdoutLines: string[] = [];
const err = await runTestRunAll(
{
profile: 'default',
output: 'text',
debug: false,
projectId: 'project_be',
wait: true,
timeoutSeconds: 60,
maxConcurrency: 5,
ghOutput: true,
summaryFile,
},
{
credentialsPath,
fetchImpl,
stdout: line => stdoutLines.push(line),
stderr: () => undefined,
env: {} as NodeJS.ProcessEnv,
sleep: instantSleep,
},
).catch(e => e);
expect(err).toMatchObject({ exitCode: 1 });
const artifact = JSON.parse(readFileSync(summaryFile, 'utf8')) as {
total: number;
passed: number;
failed: number;
runs: unknown[];
};
expect(artifact).toMatchObject({ total: 2, passed: 1, failed: 1 });
// Forced annotations (off-Actions) land on the text stdout, not the file.
expect(stdoutLines.some(line => line.startsWith('::error'))).toBe(true);
});
});
70 changes: 70 additions & 0 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
appendFileSync,
createWriteStream,
existsSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
type WriteStream,
} from 'node:fs';
import { rename, stat, unlink } from 'node:fs/promises';
Expand Down Expand Up @@ -92,6 +94,7 @@ import {
import { createTicker } from '../lib/ticker.js';
import { RateThrottle } from '../lib/rate-throttle.js';
import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js';
import { emitGithubOutputs, summarizeAcceptedPayload } from '../lib/gh-output.js';
import { loadConfig } from '../lib/config.js';
import {
flakyExitCode,
Expand Down Expand Up @@ -6363,6 +6366,10 @@ interface RunTestRunAllOptions extends CommonOptions {
reportFile?: string;
/** --report-suite-name: optional override for the JUnit <testsuite name=...>. */
reportSuiteName?: string;
/** --gh-output: force the GitHub-native output layer even off-Actions (issue #99). */
ghOutput?: boolean;
/** --summary-file: also write the reduced machine summary JSON to this path. */
summaryFile?: string;
}

async function writeBatchJUnitReportIfRequested(
Expand Down Expand Up @@ -6927,6 +6934,41 @@ export async function runTestRunAll(
};
await writeBatchJUnitReportIfRequested(opts, freshRunResults);
out.print(jsonPayload);
// CI-native output layer (issue #99): emitted before the gate throws below so
// the artifacts land even when the batch exits non-zero. The summary file is a
// machine artifact written regardless of --output mode; stdout stays owned by
// the envelope above (plus Actions workflow commands, which Actions parses).
{
const env = deps.env ?? process.env;
const ghEnabled = opts.ghOutput === true || env.GITHUB_ACTIONS === 'true';
if (ghEnabled || opts.summaryFile !== undefined) {
const ciSummary = summarizeAcceptedPayload(JSON.stringify(jsonPayload));
if (opts.summaryFile !== undefined) {
try {
writeFileSync(opts.summaryFile, `${JSON.stringify(ciSummary, null, 2)}\n`, 'utf8');
} catch {
stderrFn(`[run] could not write --summary-file ${opts.summaryFile}; continuing`);
}
}
if (ghEnabled) {
const stdoutFn = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
emitGithubOutputs(
ciSummary,
env,
{
stdout: stdoutFn,
stderr: stderrFn,
appendFile: (path: string, content: string) => appendFileSync(path, content, 'utf8'),
// Under --output json the envelope above owns stdout; workflow
// commands go to stderr instead (the Actions runner parses both
// streams), keeping the documented machine output parseable.
annotations: opts.output === 'json' ? stderrFn : stdoutFn,
},
{ force: opts.ghOutput === true },
);
}
}
}

// Rate-deferred tests were never dispatched → the batch is incomplete (exit 7),
// mirroring `test rerun --all`. Checked before the failed-run throw so the
Expand Down Expand Up @@ -9125,6 +9167,14 @@ export function createTestCommand(deps: TestDeps = {}): Command {
'--report-suite-name <name>',
'optional JUnit <testsuite name=...> override (default: testsprite:<projectId>)',
)
.option(
'--gh-output',
'with --all --wait: emit GitHub-native output (::error:: annotations per non-passed run; job-summary table when $GITHUB_STEP_SUMMARY is set). Auto-enabled when GITHUB_ACTIONS=true',
)
.option(
'--summary-file <path>',
'with --all --wait: also write the reduced machine summary JSON {total, passed, failed, timedOut, runs[]} to this file',
)
.addHelpText(
'after',
'\nDependency-aware fresh run (M4):\n' +
Expand Down Expand Up @@ -9173,6 +9223,22 @@ export function createTestCommand(deps: TestDeps = {}): Command {
wait: cmdOpts.wait === true,
batchPath: isAll,
});
// --gh-output / --summary-file reduce the terminal batch envelope, which
// only exists on the --all --wait path (without --wait the command returns
// after enqueueing). Anywhere else they would silently no-op — reject
// loudly (same rule as --filter and the JUnit report flags).
if (cmdOpts.ghOutput === true && (!isAll || cmdOpts.wait !== true)) {
throw localValidationError(
'gh-output',
'--gh-output only applies with --all --wait (it reduces the terminal batch envelope). Remove --gh-output, or add --all --wait.',
);
}
if (cmdOpts.summaryFile !== undefined && (!isAll || cmdOpts.wait !== true)) {
throw localValidationError(
'summary-file',
'--summary-file only applies with --all --wait (it reduces the terminal batch envelope). Remove --summary-file, or add --all --wait.',
);
}

if (isAll) {
// --all path: wave-ordered fresh batch run.
Expand Down Expand Up @@ -9207,6 +9273,8 @@ export function createTestCommand(deps: TestDeps = {}): Command {
report,
reportFile: cmdOpts.reportFile,
reportSuiteName: cmdOpts.reportSuiteName,
ghOutput: cmdOpts.ghOutput === true,
summaryFile: cmdOpts.summaryFile,
},
deps,
);
Expand Down Expand Up @@ -9687,6 +9755,8 @@ interface RunFlagOpts {
report?: string;
reportFile?: string;
reportSuiteName?: string;
ghOutput?: boolean;
summaryFile?: string;
}

interface WaitFlagOpts {
Expand Down
Loading
Loading