Skip to content

Commit 75a87fc

Browse files
committed
Merge upstream/main into fix/bound-response-body-size
Resolves the two conflicts in src/lib/http.ts: - Constants block: keep both MAX_RESPONSE_BYTES_DEFAULT (this branch) and MAX_SCHEMA_ISSUES_IN_DETAILS (upstream #264/#273 line). - Success-path body read: keep the bounded read, but assign the parsed value to `raw` instead of returning early, so upstream's valibot `v.safeParse(options.schema, raw)` still runs. Returning here would have made the new schema validation dead code on every request. Verified: tsc --noEmit clean, eslint clean, vitest 58 files / 2030 passed / 3 skipped.
2 parents 2d3fe69 + fe07bc9 commit 75a87fc

16 files changed

Lines changed: 1376 additions & 95 deletions

DOCUMENTATION.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -600,24 +600,25 @@ These apply to every command:
600600

601601
### Environment variables
602602

603-
| Variable | Purpose |
604-
| ----------------------------------------- | ---------------------------------------------------------------------------------------- |
605-
| `TESTSPRITE_API_KEY` | API key — overrides the credentials file |
606-
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
607-
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
608-
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
609-
| `TESTSPRITE_NO_UPDATE_NOTIFIER` | Any non-empty value disables the once-per-24h "new version available" notice |
610-
| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) |
611-
| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Standard proxy support — API traffic is routed through the configured proxy |
612-
| `TESTSPRITE_NO_SKILL_WARNING` | Any non-empty value silences the "verify skill not installed" reminder (CI / manual use) |
613-
| `TESTSPRITE_PORTAL_URL` | Override the Portal origin used for `dashboardUrl` links (non-prod environments) |
603+
| Variable | Purpose |
604+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------ |
605+
| `TESTSPRITE_API_KEY` | API key - overrides the credentials file |
606+
| `TESTSPRITE_API_URL` | API endpoint - overrides the credentials file |
607+
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
608+
| `TESTSPRITE_PROJECT_ID` | Default project for `test list`, `test create`, and `test run --all` when `--project` is omitted |
609+
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000`-`600000`) |
610+
| `TESTSPRITE_NO_UPDATE_NOTIFIER` | Any non-empty value disables the once-per-24h "new version available" notice |
611+
| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) |
612+
| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Standard proxy support - API traffic is routed through the configured proxy |
613+
| `TESTSPRITE_NO_SKILL_WARNING` | Any non-empty value silences the "verify skill not installed" reminder (CI / manual use) |
614+
| `TESTSPRITE_PORTAL_URL` | Override the Portal origin used for `dashboardUrl` links (non-prod environments) |
614615

615616
### Update notice
616617

617618
Interactive runs print a one-line "new version available" notice on stderr when
618619
a newer release exists. To learn this, the CLI contacts the public npm registry
619620
(`registry.npmjs.org`) at most once per 24 hours; the request carries the
620-
package name only never your API key, project data, or command line. The
621+
package name only - never your API key, project data, or command line. The
621622
check is skipped in CI, when stderr is not a TTY, under `--output json` /
622623
`--dry-run`, and entirely when `TESTSPRITE_NO_UPDATE_NOTIFIER` is set. Any
623624
failure is silent: the notice can never break or delay a command. This is the
@@ -627,7 +628,7 @@ Separately, the backend advertises its **minimum supported CLI version** on
627628
every `/api/cli/v1` response. When the running CLI is below that floor, a
628629
one-line upgrade advisory is printed to stderr (same opt-outs as the update
629630
notice; it never changes the exit status). A backend may also reject a
630-
too-old client outright with HTTP 426 surfaced as `CLIENT_TOO_OLD`,
631+
too-old client outright with HTTP 426 - surfaced as `CLIENT_TOO_OLD`,
631632
exit `14`, non-retriable, with upgrade guidance.
632633

633634
### Scopes

src/commands/completion.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,16 @@ describe('isShell / detectShell', () => {
2323
expect(detectShell({ SHELL: '/usr/local/bin/fish' })).toBe('fish');
2424
});
2525

26+
it('detects shells with Windows backslashes, .exe suffixes, and uppercase paths', () => {
27+
expect(detectShell({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' })).toBe('bash');
28+
expect(detectShell({ SHELL: 'C:\\tools\\zsh.exe' })).toBe('zsh');
29+
expect(detectShell({ SHELL: '/usr/bin/FISH.EXE' })).toBe('fish');
30+
expect(detectShell({ SHELL: 'C:/Program Files\\Git\\bin/bash.exe' })).toBe('bash');
31+
});
32+
2633
it('returns undefined for an unknown or missing shell', () => {
2734
expect(detectShell({ SHELL: '/bin/sh' })).toBeUndefined();
35+
expect(detectShell({ SHELL: 'C:\\Windows\\System32\\cmd.exe' })).toBeUndefined();
2836
expect(detectShell({})).toBeUndefined();
2937
});
3038
});

src/commands/completion.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export function isShell(value: string): value is Shell {
4242
/** Best-effort shell detection from `$SHELL` (e.g. "/bin/zsh" -> "zsh"). */
4343
export function detectShell(env: NodeJS.ProcessEnv): Shell | undefined {
4444
const shellPath = env.SHELL ?? '';
45-
const base = shellPath.slice(shellPath.lastIndexOf('/') + 1);
45+
const rawBase = shellPath.split(/[/\\]/).pop() ?? '';
46+
const base = rawBase.toLowerCase().replace(/\.exe$/, '');
4647
return isShell(base) ? base : undefined;
4748
}
4849

src/commands/test.run.spec.ts

Lines changed: 175 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';
@@ -2582,9 +2582,58 @@ describe('runTestRunAll — batch fresh run', () => {
25822582
await expect(test.parseAsync(['run', '--all'], { from: 'user' })).rejects.toMatchObject({
25832583
code: 'VALIDATION_ERROR',
25842584
exitCode: 5,
2585+
details: expect.objectContaining({
2586+
reason: expect.stringContaining('TESTSPRITE_PROJECT_ID'),
2587+
}),
25852588
});
25862589
});
25872590

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+
25882637
it('--all --target-url → exit 5 (target-url has no effect on BE-only batch)', async () => {
25892638
const { createTestCommand } = await import('./test.js');
25902639
const test = createTestCommand();
@@ -2676,6 +2725,7 @@ describe('runTestRunAll — batch fresh run', () => {
26762725
fetchImpl,
26772726
stdout: line => stdoutLines.push(line),
26782727
stderr: () => undefined,
2728+
env: {} as NodeJS.ProcessEnv,
26792729
sleep: instantSleep,
26802730
},
26812731
);
@@ -3409,6 +3459,7 @@ describe('[B-E2E-01] runTestRunAll --wait: non-passed runs must exit 1 (regressi
34093459
fetchImpl,
34103460
stdout: line => stdoutLines.push(line),
34113461
stderr: () => undefined,
3462+
env: {} as NodeJS.ProcessEnv,
34123463
sleep: instantSleep,
34133464
},
34143465
);
@@ -3817,6 +3868,7 @@ describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out p
38173868
fetchImpl,
38183869
stdout: line => stdoutLines.push(line),
38193870
stderr: () => undefined,
3871+
env: {} as NodeJS.ProcessEnv,
38203872
sleep: instantSleep,
38213873
},
38223874
).catch(e => e);
@@ -3908,3 +3960,125 @@ describe('runTestRun --wait — InterruptError graceful detach (DEV-331)', () =>
39083960
expect(stderrBlock).toContain('testsprite test wait run_abc');
39093961
});
39103962
});
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

Comments
 (0)