Skip to content

Commit fe07bc9

Browse files
authored
feat(test): GitHub-native CI output for "test run --all" (--gh-output, --summary-file) (TestSprite#264)
* feat(test): attach GitHub-native CI output to "test run --all" (--gh-output, --summary-file) * test(run): isolate batch-run specs from the CI runner env (GITHUB_ACTIONS leak) * fix(run): require --wait for CI-output flags, keep JSON stdout clean, harden the payload reducer
1 parent 26821e1 commit fe07bc9

5 files changed

Lines changed: 488 additions & 1 deletion

File tree

src/commands/test.run.spec.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* sleep injection is wired through `TestDeps.sleep` to avoid real delays.
66
*/
77

8-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
99
import { tmpdir } from 'node:os';
1010
import { join } from 'node:path';
1111
import type { Command } from 'commander';
@@ -2725,6 +2725,7 @@ describe('runTestRunAll — batch fresh run', () => {
27252725
fetchImpl,
27262726
stdout: line => stdoutLines.push(line),
27272727
stderr: () => undefined,
2728+
env: {} as NodeJS.ProcessEnv,
27282729
sleep: instantSleep,
27292730
},
27302731
);
@@ -3458,6 +3459,7 @@ describe('[B-E2E-01] runTestRunAll --wait: non-passed runs must exit 1 (regressi
34583459
fetchImpl,
34593460
stdout: line => stdoutLines.push(line),
34603461
stderr: () => undefined,
3462+
env: {} as NodeJS.ProcessEnv,
34613463
sleep: instantSleep,
34623464
},
34633465
);
@@ -3866,6 +3868,7 @@ describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out p
38663868
fetchImpl,
38673869
stdout: line => stdoutLines.push(line),
38683870
stderr: () => undefined,
3871+
env: {} as NodeJS.ProcessEnv,
38693872
sleep: instantSleep,
38703873
},
38713874
).catch(e => e);
@@ -3957,3 +3960,125 @@ describe('runTestRun --wait — InterruptError graceful detach (DEV-331)', () =>
39573960
expect(stderrBlock).toContain('testsprite test wait run_abc');
39583961
});
39593962
});
3963+
3964+
describe('gh-output integration on run --all --wait (issue #99 reshape)', () => {
3965+
function makeTerminalRun(runId: string, testId: string, status: string): RunResponse {
3966+
return {
3967+
runId,
3968+
testId,
3969+
projectId: 'project_be',
3970+
userId: 'user_1',
3971+
status: status as RunResponse['status'],
3972+
source: 'cli',
3973+
createdAt: '2026-06-09T11:00:00.000Z',
3974+
startedAt: '2026-06-09T11:00:01.000Z',
3975+
finishedAt: '2026-06-09T11:00:30.000Z',
3976+
codeVersion: 'v1',
3977+
targetUrl: 'https://api.example.com',
3978+
createdFrom: 'cli',
3979+
failedStepIndex: null,
3980+
failureKind: null,
3981+
error: null,
3982+
videoUrl: null,
3983+
stepSummary: {
3984+
total: 3,
3985+
completed: 3,
3986+
passedCount: status === 'passed' ? 3 : 0,
3987+
failedCount: 0,
3988+
},
3989+
};
3990+
}
3991+
3992+
function mixedHarness() {
3993+
const { credentialsPath } = makeCreds();
3994+
const mixedBatch: BatchRunFreshResponse = {
3995+
accepted: [
3996+
{ testId: 'test_p', runId: 'run_p', enqueuedAt: '2026-06-09T11:00:00.000Z' },
3997+
{ testId: 'test_f', runId: 'run_f', enqueuedAt: '2026-06-09T11:00:02.000Z' },
3998+
],
3999+
conflicts: [],
4000+
deferred: [],
4001+
skippedFrontend: [],
4002+
skippedIntegration: [],
4003+
};
4004+
const fetchImpl = makeFetch((url, init) => {
4005+
if ((init.method ?? 'GET') === 'POST') return { body: mixedBatch };
4006+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
4007+
if (runId === 'run_p') return { body: makeTerminalRun('run_p', 'test_p', 'passed') };
4008+
if (runId === 'run_f') return { body: makeTerminalRun('run_f', 'test_f', 'failed') };
4009+
return errorBody('NOT_FOUND');
4010+
});
4011+
return { credentialsPath, fetchImpl };
4012+
}
4013+
4014+
it('under Actions with --output json: stdout stays parseable JSON, ::error:: goes to stderr', async () => {
4015+
const { credentialsPath, fetchImpl } = mixedHarness();
4016+
const stdoutLines: string[] = [];
4017+
const stderrLines: string[] = [];
4018+
const err = await runTestRunAll(
4019+
{
4020+
profile: 'default',
4021+
output: 'json',
4022+
debug: false,
4023+
projectId: 'project_be',
4024+
wait: true,
4025+
timeoutSeconds: 60,
4026+
maxConcurrency: 5,
4027+
},
4028+
{
4029+
credentialsPath,
4030+
fetchImpl,
4031+
stdout: line => stdoutLines.push(line),
4032+
stderr: line => stderrLines.push(line),
4033+
env: { GITHUB_ACTIONS: 'true' } as NodeJS.ProcessEnv,
4034+
sleep: instantSleep,
4035+
},
4036+
).catch(e => e);
4037+
expect(err).toMatchObject({ exitCode: 1 });
4038+
// The documented machine envelope must remain parseable as-is.
4039+
const payload = JSON.parse(stdoutLines.join('\n')) as { accepted?: unknown[] };
4040+
expect(Array.isArray(payload.accepted)).toBe(true);
4041+
expect(stdoutLines.some(line => line.startsWith('::error'))).toBe(false);
4042+
const annotations = stderrLines.filter(line => line.startsWith('::error'));
4043+
expect(annotations).toHaveLength(1);
4044+
expect(annotations[0]).toContain('test_f');
4045+
});
4046+
4047+
it('--gh-output --summary-file writes the reduced artifact even though the gate exits 1', async () => {
4048+
const { credentialsPath, fetchImpl } = mixedHarness();
4049+
const dir = mkdtempSync(join(tmpdir(), 'cli-gh-output-'));
4050+
const summaryFile = join(dir, 'summary.json');
4051+
const stdoutLines: string[] = [];
4052+
const err = await runTestRunAll(
4053+
{
4054+
profile: 'default',
4055+
output: 'text',
4056+
debug: false,
4057+
projectId: 'project_be',
4058+
wait: true,
4059+
timeoutSeconds: 60,
4060+
maxConcurrency: 5,
4061+
ghOutput: true,
4062+
summaryFile,
4063+
},
4064+
{
4065+
credentialsPath,
4066+
fetchImpl,
4067+
stdout: line => stdoutLines.push(line),
4068+
stderr: () => undefined,
4069+
env: {} as NodeJS.ProcessEnv,
4070+
sleep: instantSleep,
4071+
},
4072+
).catch(e => e);
4073+
expect(err).toMatchObject({ exitCode: 1 });
4074+
const artifact = JSON.parse(readFileSync(summaryFile, 'utf8')) as {
4075+
total: number;
4076+
passed: number;
4077+
failed: number;
4078+
runs: unknown[];
4079+
};
4080+
expect(artifact).toMatchObject({ total: 2, passed: 1, failed: 1 });
4081+
// Forced annotations (off-Actions) land on the text stdout, not the file.
4082+
expect(stdoutLines.some(line => line.startsWith('::error'))).toBe(true);
4083+
});
4084+
});

src/commands/test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
2+
appendFileSync,
23
createWriteStream,
34
existsSync,
45
readFileSync,
56
readdirSync,
67
statSync,
8+
writeFileSync,
79
type WriteStream,
810
} from 'node:fs';
911
import { rename, stat, unlink } from 'node:fs/promises';
@@ -92,6 +94,7 @@ import {
9294
import { createTicker } from '../lib/ticker.js';
9395
import { RateThrottle } from '../lib/rate-throttle.js';
9496
import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js';
97+
import { emitGithubOutputs, summarizeAcceptedPayload } from '../lib/gh-output.js';
9598
import { loadConfig } from '../lib/config.js';
9699
import {
97100
flakyExitCode,
@@ -6363,6 +6366,10 @@ interface RunTestRunAllOptions extends CommonOptions {
63636366
reportFile?: string;
63646367
/** --report-suite-name: optional override for the JUnit <testsuite name=...>. */
63656368
reportSuiteName?: string;
6369+
/** --gh-output: force the GitHub-native output layer even off-Actions (issue #99). */
6370+
ghOutput?: boolean;
6371+
/** --summary-file: also write the reduced machine summary JSON to this path. */
6372+
summaryFile?: string;
63666373
}
63676374

63686375
async function writeBatchJUnitReportIfRequested(
@@ -6927,6 +6934,41 @@ export async function runTestRunAll(
69276934
};
69286935
await writeBatchJUnitReportIfRequested(opts, freshRunResults);
69296936
out.print(jsonPayload);
6937+
// CI-native output layer (issue #99): emitted before the gate throws below so
6938+
// the artifacts land even when the batch exits non-zero. The summary file is a
6939+
// machine artifact written regardless of --output mode; stdout stays owned by
6940+
// the envelope above (plus Actions workflow commands, which Actions parses).
6941+
{
6942+
const env = deps.env ?? process.env;
6943+
const ghEnabled = opts.ghOutput === true || env.GITHUB_ACTIONS === 'true';
6944+
if (ghEnabled || opts.summaryFile !== undefined) {
6945+
const ciSummary = summarizeAcceptedPayload(JSON.stringify(jsonPayload));
6946+
if (opts.summaryFile !== undefined) {
6947+
try {
6948+
writeFileSync(opts.summaryFile, `${JSON.stringify(ciSummary, null, 2)}\n`, 'utf8');
6949+
} catch {
6950+
stderrFn(`[run] could not write --summary-file ${opts.summaryFile}; continuing`);
6951+
}
6952+
}
6953+
if (ghEnabled) {
6954+
const stdoutFn = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
6955+
emitGithubOutputs(
6956+
ciSummary,
6957+
env,
6958+
{
6959+
stdout: stdoutFn,
6960+
stderr: stderrFn,
6961+
appendFile: (path: string, content: string) => appendFileSync(path, content, 'utf8'),
6962+
// Under --output json the envelope above owns stdout; workflow
6963+
// commands go to stderr instead (the Actions runner parses both
6964+
// streams), keeping the documented machine output parseable.
6965+
annotations: opts.output === 'json' ? stderrFn : stdoutFn,
6966+
},
6967+
{ force: opts.ghOutput === true },
6968+
);
6969+
}
6970+
}
6971+
}
69306972

69316973
// Rate-deferred tests were never dispatched → the batch is incomplete (exit 7),
69326974
// mirroring `test rerun --all`. Checked before the failed-run throw so the
@@ -9125,6 +9167,14 @@ export function createTestCommand(deps: TestDeps = {}): Command {
91259167
'--report-suite-name <name>',
91269168
'optional JUnit <testsuite name=...> override (default: testsprite:<projectId>)',
91279169
)
9170+
.option(
9171+
'--gh-output',
9172+
'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',
9173+
)
9174+
.option(
9175+
'--summary-file <path>',
9176+
'with --all --wait: also write the reduced machine summary JSON {total, passed, failed, timedOut, runs[]} to this file',
9177+
)
91289178
.addHelpText(
91299179
'after',
91309180
'\nDependency-aware fresh run (M4):\n' +
@@ -9173,6 +9223,22 @@ export function createTestCommand(deps: TestDeps = {}): Command {
91739223
wait: cmdOpts.wait === true,
91749224
batchPath: isAll,
91759225
});
9226+
// --gh-output / --summary-file reduce the terminal batch envelope, which
9227+
// only exists on the --all --wait path (without --wait the command returns
9228+
// after enqueueing). Anywhere else they would silently no-op — reject
9229+
// loudly (same rule as --filter and the JUnit report flags).
9230+
if (cmdOpts.ghOutput === true && (!isAll || cmdOpts.wait !== true)) {
9231+
throw localValidationError(
9232+
'gh-output',
9233+
'--gh-output only applies with --all --wait (it reduces the terminal batch envelope). Remove --gh-output, or add --all --wait.',
9234+
);
9235+
}
9236+
if (cmdOpts.summaryFile !== undefined && (!isAll || cmdOpts.wait !== true)) {
9237+
throw localValidationError(
9238+
'summary-file',
9239+
'--summary-file only applies with --all --wait (it reduces the terminal batch envelope). Remove --summary-file, or add --all --wait.',
9240+
);
9241+
}
91769242

91779243
if (isAll) {
91789244
// --all path: wave-ordered fresh batch run.
@@ -9207,6 +9273,8 @@ export function createTestCommand(deps: TestDeps = {}): Command {
92079273
report,
92089274
reportFile: cmdOpts.reportFile,
92099275
reportSuiteName: cmdOpts.reportSuiteName,
9276+
ghOutput: cmdOpts.ghOutput === true,
9277+
summaryFile: cmdOpts.summaryFile,
92109278
},
92119279
deps,
92129280
);
@@ -9687,6 +9755,8 @@ interface RunFlagOpts {
96879755
report?: string;
96889756
reportFile?: string;
96899757
reportSuiteName?: string;
9758+
ghOutput?: boolean;
9759+
summaryFile?: string;
96909760
}
96919761

96929762
interface WaitFlagOpts {

0 commit comments

Comments
 (0)