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 = { - projectId: opts.projectId, + projectId, type: opts.type, name: opts.name, description: opts.description, @@ -915,7 +917,7 @@ export async function runCreate( // B3: best-effort duplicate-name advisory. Skip under --dry-run. if (!opts.dryRun) { const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); - await emitDupNameAdvisoryIfNeeded(client, opts.projectId, opts.name, stderrFn); + await emitDupNameAdvisoryIfNeeded(client, projectId, opts.name, stderrFn); } const response = await client.post('/tests', { @@ -940,7 +942,7 @@ export async function runCreate( // R1: suppress under --dry-run (fake canned test id). const chainDashboardUrl = opts.dryRun ? undefined - : resolvePortalUrl(resolveApiUrl(opts, deps), opts.projectId, response.testId); + : resolvePortalUrl(resolveApiUrl(opts, deps), projectId, response.testId); const createContextWithUrl = chainDashboardUrl !== undefined ? { ...response, dashboardUrl: chainDashboardUrl } : response; await runTestRun( @@ -970,7 +972,7 @@ export async function runCreate( // (e.g. "test_dryrun_create_2026") and a live-looking URL would mislead. const dashboardUrl = opts.dryRun ? undefined - : resolvePortalUrl(resolveApiUrl(opts, deps), opts.projectId, response.testId); + : resolvePortalUrl(resolveApiUrl(opts, deps), projectId, response.testId); if (opts.output === 'json') { out.print(dashboardUrl !== undefined ? { ...response, dashboardUrl } : response, data => renderCreateText(data as CliCreateTestResponse), @@ -6327,8 +6329,8 @@ export async function runTestWait( // --------------------------------------------------------------------------- interface RunTestRunAllOptions extends CommonOptions { - /** projectId to run all tests in. */ - projectId: string; + /** projectId to run all tests in; may be resolved from --project or TESTSPRITE_PROJECT_ID. */ + projectId?: string; /** --filter : only run tests whose name contains this substring (case-insensitive). */ nameFilter?: string; /** --wait: block until terminal or --timeout. */ @@ -6393,7 +6395,8 @@ export async function runTestRunAll( deps: TestDeps = {}, ): Promise { assertIdempotencyKey(opts.idempotencyKey); - requireProjectId(opts.projectId); + const projectId = resolveProjectId(opts.projectId, deps); + requireProjectId(projectId); if ( !Number.isInteger(opts.maxConcurrency) || opts.maxConcurrency < 1 || @@ -6424,7 +6427,7 @@ export async function runTestRunAll( method: 'POST', path: '/api/cli/v1/tests/batch/run', body: { - projectId: opts.projectId, + projectId, testIds: opts.nameFilter ? [''] : undefined, source: 'cli' as const, }, @@ -6453,9 +6456,9 @@ export async function runTestRunAll( const projectDashboardUrl = batchPortalBase === undefined ? undefined - : `${batchPortalBase}/dashboard/tests/${encodeURIComponent(opts.projectId)}`; + : `${batchPortalBase}/dashboard/tests/${encodeURIComponent(projectId)}`; const withBatchDashboardUrl = (item: T): T => { - const dashboardUrl = resolvePortalUrl(batchApiUrl, opts.projectId, item.testId); + const dashboardUrl = resolvePortalUrl(batchApiUrl, projectId, item.testId); return dashboardUrl !== undefined ? { ...item, dashboardUrl } : item; }; @@ -6471,7 +6474,7 @@ export async function runTestRunAll( const allPage = await paginate( async ({ pageSize, cursor }) => client.get>('/tests', { - query: { projectId: opts.projectId, pageSize, cursor }, + query: { projectId, pageSize, cursor }, }), {}, ); @@ -6487,7 +6490,7 @@ export async function runTestRunAll( testIds = filtered.map(t => t.id); if (testIds.length === 0) { stderrFn( - `No tests found in project ${opts.projectId} matching --filter "${opts.nameFilter}" — nothing to run.`, + `No tests found in project ${projectId} matching --filter "${opts.nameFilter}" — nothing to run.`, ); out.print({ accepted: [], @@ -6499,7 +6502,7 @@ export async function runTestRunAll( return undefined; } stderrFn( - `Resolved ${testIds.length} test${testIds.length !== 1 ? 's' : ''} in project ${opts.projectId} for batch run.`, + `Resolved ${testIds.length} test${testIds.length !== 1 ? 's' : ''} in project ${projectId} for batch run.`, ); } // When no --filter, omit testIds → server runs ALL tests in the project @@ -6507,7 +6510,7 @@ export async function runTestRunAll( const batchResp = await client.triggerBatchRunFresh( { - projectId: opts.projectId, + projectId, ...(testIds !== undefined ? { testIds } : {}), source: 'cli', }, @@ -6651,7 +6654,7 @@ export async function runTestRunAll( try { retryResp = await client.triggerBatchRunFresh( { - projectId: opts.projectId, + projectId, testIds: retryIds, source: 'cli', }, @@ -9082,12 +9085,12 @@ export function createTestCommand(deps: TestDeps = {}): Command { ) .option( '--all', - 'run all tests in the project (wave-ordered fresh run; requires --project). Mutually exclusive with .', + 'run all tests in the project (wave-ordered fresh run; uses --project or TESTSPRITE_PROJECT_ID). Mutually exclusive with .', false, ) .option( '--project ', - 'project id (required with --all; returned by `testsprite project list`)', + 'project id (with --all, overrides TESTSPRITE_PROJECT_ID; returned by `testsprite project list`)', ) .option( '--filter ', @@ -9109,9 +9112,11 @@ export function createTestCommand(deps: TestDeps = {}): Command { .addHelpText( 'after', '\nDependency-aware fresh run (M4):\n' + - ' testsprite test run --all --project run all project tests in wave order\n' + - ' testsprite test run --all --project --filter name-glob subset\n' + - ' testsprite test run --all --project --wait --report junit --report-file ./results.xml\n' + + ' testsprite test run --all --project run all project tests in wave order\n' + + ' TESTSPRITE_PROJECT_ID= testsprite test run --all use env default project\n' + + ' testsprite test run --all --filter name-glob subset (uses --project/env)\n' + + ' testsprite test run --all --wait --report junit --report-file ./results.xml\n' + + ' project id precedence: --project wins over TESTSPRITE_PROJECT_ID\n' + '\nBE tests can declare --produces/--needs at create time to drive wave ordering\n' + '(see `testsprite test create --help` for details).\n' + '\nFrontend tests: the current unified engine runs FE tests too (they are billed\n' + @@ -9132,7 +9137,7 @@ export function createTestCommand(deps: TestDeps = {}): Command { if (testIdArg === undefined && !isAll) { throw localValidationError( 'test-id', - 'provide a , or use --all --project to run all tests in a project', + 'provide a , or use --all with --project or TESTSPRITE_PROJECT_ID', ); } // --filter is an --all-only narrowing flag (mirrors `test rerun --filter`). @@ -9141,7 +9146,7 @@ export function createTestCommand(deps: TestDeps = {}): Command { if (cmdOpts.filter !== undefined && cmdOpts.filter !== '' && !isAll) { throw localValidationError( 'filter', - '--filter only applies with --all (it narrows which project tests run). Remove --filter, or add --all --project .', + '--filter only applies with --all (it narrows which project tests run). Remove --filter, or add --all with --project or TESTSPRITE_PROJECT_ID.', ); } const report = parseJUnitReportFormat(cmdOpts.report); @@ -9155,18 +9160,17 @@ export function createTestCommand(deps: TestDeps = {}): Command { if (isAll) { // --all path: wave-ordered fresh batch run. - if (!cmdOpts.project) { - throw localValidationError( - 'project', - '--all requires a project id — pass --project ', - ); - } + const projectId = resolveProjectId(cmdOpts.project, deps); + requireProjectId( + projectId, + '--all requires a project id - pass --project or set TESTSPRITE_PROJECT_ID', + ); // --target-url has no effect on the --all batch path: a BE test's base // URL is baked into its code, and the unified engine resolves each // project's configured environment server-side (per-run URL overrides // are not applied to batch FE runs either). Silently dropping it could - // run the suite against an unintended environment in the caller's mind - // — reject loudly instead. + // run the suite against an unintended environment in the caller's mind, + // so reject loudly. if (cmdOpts.targetUrl !== undefined && cmdOpts.targetUrl !== '') { throw localValidationError( 'target-url', @@ -9176,7 +9180,7 @@ export function createTestCommand(deps: TestDeps = {}): Command { await runTestRunAll( { ...resolveCommonOptions(command), - projectId: cmdOpts.project, + projectId, nameFilter: cmdOpts.filter, wait: cmdOpts.wait === true, timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'), @@ -9785,9 +9789,19 @@ interface StepsFlagOpts { runId?: string; } -function requireProjectId(projectId: string): void { +function resolveProjectId(projectId: string | undefined, deps: TestDeps): string | undefined { + const explicit = projectId?.trim(); + if (explicit && explicit.length > 0) return explicit; + const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID; + const trimmed = envValue?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} +function requireProjectId( + projectId: string | undefined, + message = 'is required; pass --project or set TESTSPRITE_PROJECT_ID', +): asserts projectId is string { if (typeof projectId !== 'string' || projectId.length === 0) { - throw localValidationError('project', 'is required'); + throw localValidationError('project', message); } } diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 4724064..55a7252 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -664,10 +664,12 @@ Options: --idempotency-key opaque key for safe retries (1–256 chars). Printed to stderr at --debug if auto-generated. --all run all tests in the project (wave-ordered fresh - run; requires --project). Mutually exclusive with - . (default: false) - --project project id (required with --all; returned by - \`testsprite project list\`) + run; uses --project or TESTSPRITE_PROJECT_ID). + Mutually exclusive with . (default: + false) + --project project id (with --all, overrides + TESTSPRITE_PROJECT_ID; returned by \`testsprite + project list\`) --filter with --all: only run tests whose name contains this substring (case-insensitive) --max-concurrency with --all --wait, max in-flight polls at once @@ -680,9 +682,11 @@ Options: -h, --help display help for command Dependency-aware fresh run (M4): - testsprite test run --all --project run all project tests in wave order - testsprite test run --all --project --filter name-glob subset - testsprite test run --all --project --wait --report junit --report-file ./results.xml + testsprite test run --all --project run all project tests in wave order + TESTSPRITE_PROJECT_ID= testsprite test run --all use env default project + testsprite test run --all --filter name-glob subset (uses --project/env) + testsprite test run --all --wait --report junit --report-file ./results.xml + project id precedence: --project wins over TESTSPRITE_PROJECT_ID BE tests can declare --produces/--needs at create time to drive wave ordering (see \`testsprite test create --help\` for details).