diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bf6b1b5..2e01b6e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -600,24 +600,25 @@ These apply to every command: ### Environment variables -| Variable | Purpose | -| ----------------------------------------- | ---------------------------------------------------------------------------------------- | -| `TESTSPRITE_API_KEY` | API key — overrides the credentials file | -| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file | -| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) | -| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000`–`600000`) | -| `TESTSPRITE_NO_UPDATE_NOTIFIER` | Any non-empty value disables the once-per-24h "new version available" notice | -| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) | -| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Standard proxy support — API traffic is routed through the configured proxy | -| `TESTSPRITE_NO_SKILL_WARNING` | Any non-empty value silences the "verify skill not installed" reminder (CI / manual use) | -| `TESTSPRITE_PORTAL_URL` | Override the Portal origin used for `dashboardUrl` links (non-prod environments) | +| Variable | Purpose | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `TESTSPRITE_API_KEY` | API key - overrides the credentials file | +| `TESTSPRITE_API_URL` | API endpoint - overrides the credentials file | +| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) | +| `TESTSPRITE_PROJECT_ID` | Default project for `test list`, `test create`, and `test run --all` when `--project` is omitted | +| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000`-`600000`) | +| `TESTSPRITE_NO_UPDATE_NOTIFIER` | Any non-empty value disables the once-per-24h "new version available" notice | +| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) | +| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Standard proxy support - API traffic is routed through the configured proxy | +| `TESTSPRITE_NO_SKILL_WARNING` | Any non-empty value silences the "verify skill not installed" reminder (CI / manual use) | +| `TESTSPRITE_PORTAL_URL` | Override the Portal origin used for `dashboardUrl` links (non-prod environments) | ### Update notice Interactive runs print a one-line "new version available" notice on stderr when a newer release exists. To learn this, the CLI contacts the public npm registry (`registry.npmjs.org`) at most once per 24 hours; the request carries the -package name only — never your API key, project data, or command line. The +package name only - never your API key, project data, or command line. The check is skipped in CI, when stderr is not a TTY, under `--output json` / `--dry-run`, and entirely when `TESTSPRITE_NO_UPDATE_NOTIFIER` is set. Any 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 every `/api/cli/v1` response. When the running CLI is below that floor, a one-line upgrade advisory is printed to stderr (same opt-outs as the update notice; it never changes the exit status). A backend may also reject a -too-old client outright with HTTP 426 — surfaced as `CLIENT_TOO_OLD`, +too-old client outright with HTTP 426 - surfaced as `CLIENT_TOO_OLD`, exit `14`, non-retriable, with upgrade guidance. ### Scopes diff --git a/src/commands/test.run.spec.ts b/src/commands/test.run.spec.ts index 51b527f..7eb8eb0 100644 --- a/src/commands/test.run.spec.ts +++ b/src/commands/test.run.spec.ts @@ -2582,9 +2582,58 @@ describe('runTestRunAll — batch fresh run', () => { await expect(test.parseAsync(['run', '--all'], { from: 'user' })).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5, + details: expect.objectContaining({ + reason: expect.stringContaining('TESTSPRITE_PROJECT_ID'), + }), }); }); + it('run --all uses TESTSPRITE_PROJECT_ID when --project is omitted', async () => { + const { createTestCommand } = await import('./test.js'); + const { credentialsPath } = makeCreds(); + type Captured = { url: string; method: string; body: unknown }; + const captured: Captured[] = []; + const fetchImpl = makeFetch((url, init) => { + const method = init.method ?? 'GET'; + captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined }); + return { body: BATCH_FRESH_RESP }; + }); + const test = createTestCommand({ + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + stderr: () => undefined, + sleep: instantSleep, + }); + await test.parseAsync(['run', '--all'], { from: 'user' }); + const post = captured.find(c => c.method === 'POST' && c.url.includes('/tests/batch/run'))!; + expect(post.body).toMatchObject({ projectId: 'project_env', source: 'cli' }); + }); + + it('run --all uses TESTSPRITE_PROJECT_ID when --project is blank', async () => { + const { createTestCommand } = await import('./test.js'); + const { credentialsPath } = makeCreds(); + type Captured = { url: string; method: string; body: unknown }; + const captured: Captured[] = []; + const fetchImpl = makeFetch((url, init) => { + const method = init.method ?? 'GET'; + captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined }); + return { body: BATCH_FRESH_RESP }; + }); + const test = createTestCommand({ + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + stderr: () => undefined, + sleep: instantSleep, + }); + await test.parseAsync(['run', '--all', '--project', ' '], { from: 'user' }); + const post = captured.find(c => c.method === 'POST' && c.url.includes('/tests/batch/run'))!; + expect(post.body).toMatchObject({ projectId: 'project_env', source: 'cli' }); + }); + it('--all --target-url → exit 5 (target-url has no effect on BE-only batch)', async () => { const { createTestCommand } = await import('./test.js'); const test = createTestCommand(); diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index c8fe5a5..1922448 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -365,6 +365,64 @@ describe('runList', () => { expect(seen[0]).toContain('createdFrom=portal'); }); + it('uses TESTSPRITE_PROJECT_ID when --project is omitted', async () => { + const { credentialsPath } = makeCreds(); + const seen: string[] = []; + const fetchImpl = makeFetch(url => { + seen.push(url); + return { body: { items: [FE_TEST], nextToken: null } }; + }); + await runList( + { profile: 'default', output: 'json', debug: false }, + { + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + }, + ); + expect(seen[0]).toContain('projectId=project_env'); + }); + + it('uses TESTSPRITE_PROJECT_ID when --project is blank', async () => { + const { credentialsPath } = makeCreds(); + const seen: string[] = []; + const fetchImpl = makeFetch(url => { + seen.push(url); + return { body: { items: [FE_TEST], nextToken: null } }; + }); + await runList( + { profile: 'default', output: 'json', debug: false, projectId: ' ' }, + { + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + }, + ); + expect(seen[0]).toContain('projectId=project_env'); + }); + + it('prefers explicit --project over TESTSPRITE_PROJECT_ID', async () => { + const { credentialsPath } = makeCreds(); + const seen: string[] = []; + const fetchImpl = makeFetch(url => { + seen.push(url); + return { body: { items: [FE_TEST], nextToken: null } }; + }); + await runList( + { profile: 'default', output: 'json', debug: false, projectId: 'project_flag' }, + { + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + }, + ); + expect(seen[0]).toContain('projectId=project_flag'); + expect(seen[0]).not.toContain('projectId=project_env'); + }); + it('accepts --created-from cli and passes createdFrom=cli to the wire (dogfood 2026-06-04)', async () => { // End-to-end through parseEnumFlag: backend now stamps createFrom='cli' // on `testsprite test create` rows, so the filter must accept 'cli'. @@ -783,10 +841,15 @@ describe('createTestCommand list — required flag', () => { await test.parseAsync(['list'], { from: 'user' }); expect.unreachable('expected ApiError'); } catch (err) { - const apiErr = err as { code?: string; exitCode?: number; details?: { field?: string } }; + const apiErr = err as { + code?: string; + exitCode?: number; + details?: { field?: string; reason?: string }; + }; expect(apiErr.code).toBe('VALIDATION_ERROR'); expect(apiErr.exitCode).toBe(5); expect(apiErr.details?.field).toBe('project'); + expect(apiErr.details?.reason).toContain('TESTSPRITE_PROJECT_ID'); } }); @@ -4847,7 +4910,7 @@ describe('runCreate', () => { ...SAMPLE_RESPONSE, type: 'backend', warnings: [ - 'This test appears to hardcode an auth credential — read auth from __AUTH_HEADERS__.', + 'This test appears to hardcode an auth credential - read auth from __AUTH_HEADERS__.', ], }, }; @@ -4873,10 +4936,43 @@ describe('runCreate', () => { ); // Warning lands on stderr, prefixed `[warn]`. expect(err.some(l => l.includes('[warn]') && l.includes('__AUTH_HEADERS__'))).toBe(true); - // stdout stays the parseable wire object — no warning noise. + // stdout stays the parseable wire object - no warning noise. expect(out.join('\n')).not.toContain('[warn]'); }); + it('uses TESTSPRITE_PROJECT_ID for create when --project is omitted', async () => { + const { credentialsPath } = makeCreds(); + const codeFile = writeCodeFile('code body'); + type Captured = { method: string; body: unknown; url: string }; + const captured: Captured[] = []; + const fetchImpl = makeFetch((url, init) => { + const method = init.method ?? 'GET'; + captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined }); + if (method === 'GET') return { status: 200, body: { items: [] } }; + return { status: 200, body: SAMPLE_RESPONSE }; + }); + await runCreate( + { + profile: 'default', + output: 'json', + debug: false, + type: 'frontend', + name: 'n', + codeFile, + }, + { + credentialsPath, + env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv, + fetchImpl, + stdout: () => undefined, + }, + ); + expect(captured.some(c => c.method === 'GET' && c.url.includes('projectId=project_env'))).toBe( + true, + ); + const post = captured.find(c => c.method === 'POST')!; + expect(post.body).toMatchObject({ projectId: 'project_env' }); + }); it('respects a caller-supplied --idempotency-key (for safe retries)', async () => { const { credentialsPath } = makeCreds(); const codeFile = writeCodeFile('code body'); @@ -5030,7 +5126,6 @@ describe('runCreate', () => { profile: 'default', output: 'json', debug: false, - // @ts-expect-error — exercising the runtime gate projectId: undefined, type: 'frontend', name: 'n', diff --git a/src/commands/test.ts b/src/commands/test.ts index 7839ca7..20f3b81 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -487,7 +487,7 @@ function interruptDetachMessage(err: InterruptError, runIds: string[]): string { type CommonOptions = FactoryCommonOptions; interface ListOptions extends CommonOptions { - projectId: string; + projectId?: string; type?: 'frontend' | 'backend'; createdFrom?: 'portal' | 'mcp' | 'cli'; /** @@ -552,7 +552,8 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise
= {
- projectId: opts.projectId,
+ projectId,
type: opts.type,
createdFrom: opts.createdFrom,
status: opts.status,
@@ -642,7 +643,7 @@ export type CliCreatePriority = (typeof CLI_CREATE_PRIORITIES)[number];
const MAX_INLINE_CODE_BYTES = 350 * 1024;
interface CreateOptions extends CommonOptions {
- projectId: string;
+ projectId?: string;
type: 'frontend' | 'backend';
name: string;
description?: string;
@@ -790,7 +791,8 @@ export async function runCreate(
assertChainedRunKeyFits(opts.run, opts.idempotencyKey);
// Validate inputs before touching credentials or fs — matches the
// M2 read commands' "input gates first, then auth, then I/O" ordering.
- requireProjectId(opts.projectId);
+ const projectId = resolveProjectId(opts.projectId, deps);
+ requireProjectId(projectId);
requireNonEmpty('name', opts.name);
// P1-3: client-side length checks matching server limits (name ≤200,
// description ≤2000) so the user gets instant, actionable errors instead
@@ -871,7 +873,7 @@ export async function runCreate(
}
const body: Record