|
5 | 5 | * sleep injection is wired through `TestDeps.sleep` to avoid real delays. |
6 | 6 | */ |
7 | 7 |
|
8 | | -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; |
| 8 | +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; |
9 | 9 | import { tmpdir } from 'node:os'; |
10 | 10 | import { join } from 'node:path'; |
11 | 11 | import type { Command } from 'commander'; |
@@ -2582,9 +2582,58 @@ describe('runTestRunAll — batch fresh run', () => { |
2582 | 2582 | await expect(test.parseAsync(['run', '--all'], { from: 'user' })).rejects.toMatchObject({ |
2583 | 2583 | code: 'VALIDATION_ERROR', |
2584 | 2584 | exitCode: 5, |
| 2585 | + details: expect.objectContaining({ |
| 2586 | + reason: expect.stringContaining('TESTSPRITE_PROJECT_ID'), |
| 2587 | + }), |
2585 | 2588 | }); |
2586 | 2589 | }); |
2587 | 2590 |
|
| 2591 | + it('run --all uses TESTSPRITE_PROJECT_ID when --project is omitted', async () => { |
| 2592 | + const { createTestCommand } = await import('./test.js'); |
| 2593 | + const { credentialsPath } = makeCreds(); |
| 2594 | + type Captured = { url: string; method: string; body: unknown }; |
| 2595 | + const captured: Captured[] = []; |
| 2596 | + const fetchImpl = makeFetch((url, init) => { |
| 2597 | + const method = init.method ?? 'GET'; |
| 2598 | + captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined }); |
| 2599 | + return { body: BATCH_FRESH_RESP }; |
| 2600 | + }); |
| 2601 | + const test = createTestCommand({ |
| 2602 | + credentialsPath, |
| 2603 | + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, |
| 2604 | + fetchImpl, |
| 2605 | + stdout: () => undefined, |
| 2606 | + stderr: () => undefined, |
| 2607 | + sleep: instantSleep, |
| 2608 | + }); |
| 2609 | + await test.parseAsync(['run', '--all'], { from: 'user' }); |
| 2610 | + const post = captured.find(c => c.method === 'POST' && c.url.includes('/tests/batch/run'))!; |
| 2611 | + expect(post.body).toMatchObject({ projectId: 'project_env', source: 'cli' }); |
| 2612 | + }); |
| 2613 | + |
| 2614 | + it('run --all uses TESTSPRITE_PROJECT_ID when --project is blank', async () => { |
| 2615 | + const { createTestCommand } = await import('./test.js'); |
| 2616 | + const { credentialsPath } = makeCreds(); |
| 2617 | + type Captured = { url: string; method: string; body: unknown }; |
| 2618 | + const captured: Captured[] = []; |
| 2619 | + const fetchImpl = makeFetch((url, init) => { |
| 2620 | + const method = init.method ?? 'GET'; |
| 2621 | + captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined }); |
| 2622 | + return { body: BATCH_FRESH_RESP }; |
| 2623 | + }); |
| 2624 | + const test = createTestCommand({ |
| 2625 | + credentialsPath, |
| 2626 | + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, |
| 2627 | + fetchImpl, |
| 2628 | + stdout: () => undefined, |
| 2629 | + stderr: () => undefined, |
| 2630 | + sleep: instantSleep, |
| 2631 | + }); |
| 2632 | + await test.parseAsync(['run', '--all', '--project', ' '], { from: 'user' }); |
| 2633 | + const post = captured.find(c => c.method === 'POST' && c.url.includes('/tests/batch/run'))!; |
| 2634 | + expect(post.body).toMatchObject({ projectId: 'project_env', source: 'cli' }); |
| 2635 | + }); |
| 2636 | + |
2588 | 2637 | it('--all --target-url → exit 5 (target-url has no effect on BE-only batch)', async () => { |
2589 | 2638 | const { createTestCommand } = await import('./test.js'); |
2590 | 2639 | const test = createTestCommand(); |
@@ -2676,6 +2725,7 @@ describe('runTestRunAll — batch fresh run', () => { |
2676 | 2725 | fetchImpl, |
2677 | 2726 | stdout: line => stdoutLines.push(line), |
2678 | 2727 | stderr: () => undefined, |
| 2728 | + env: {} as NodeJS.ProcessEnv, |
2679 | 2729 | sleep: instantSleep, |
2680 | 2730 | }, |
2681 | 2731 | ); |
@@ -3409,6 +3459,7 @@ describe('[B-E2E-01] runTestRunAll --wait: non-passed runs must exit 1 (regressi |
3409 | 3459 | fetchImpl, |
3410 | 3460 | stdout: line => stdoutLines.push(line), |
3411 | 3461 | stderr: () => undefined, |
| 3462 | + env: {} as NodeJS.ProcessEnv, |
3412 | 3463 | sleep: instantSleep, |
3413 | 3464 | }, |
3414 | 3465 | ); |
@@ -3817,6 +3868,7 @@ describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out p |
3817 | 3868 | fetchImpl, |
3818 | 3869 | stdout: line => stdoutLines.push(line), |
3819 | 3870 | stderr: () => undefined, |
| 3871 | + env: {} as NodeJS.ProcessEnv, |
3820 | 3872 | sleep: instantSleep, |
3821 | 3873 | }, |
3822 | 3874 | ).catch(e => e); |
@@ -3908,3 +3960,125 @@ describe('runTestRun --wait — InterruptError graceful detach (DEV-331)', () => |
3908 | 3960 | expect(stderrBlock).toContain('testsprite test wait run_abc'); |
3909 | 3961 | }); |
3910 | 3962 | }); |
| 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 | +}); |
0 commit comments