Skip to content

Commit ac98599

Browse files
feat: support project env default
1 parent e53257d commit ac98599

4 files changed

Lines changed: 134 additions & 30 deletions

File tree

DOCUMENTATION.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -426,12 +426,13 @@ These apply to every command:
426426

427427
### Environment variables
428428

429-
| Variable | Purpose |
430-
| ------------------------------- | --------------------------------------------------------------------------------- |
431-
| `TESTSPRITE_API_KEY` | API key — overrides the credentials file |
432-
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
433-
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
434-
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
429+
| Variable | Purpose |
430+
| ------------------------------- | ------------------------------------------------------------------------------------------------ |
431+
| `TESTSPRITE_API_KEY` | API key — overrides the credentials file |
432+
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
433+
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
434+
| `TESTSPRITE_PROJECT_ID` | Default project for `test list`, `test create`, and `test run --all` when `--project` is omitted |
435+
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
435436

436437
### Scopes
437438

src/commands/test.run.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2434,6 +2434,28 @@ describe('runTestRunAll — batch fresh run', () => {
24342434
});
24352435
});
24362436

2437+
it('run --all uses TESTSPRITE_PROJECT_ID when --project is omitted', async () => {
2438+
const { createTestCommand } = await import('./test.js');
2439+
const { credentialsPath } = makeCreds();
2440+
type Captured = { url: string; method: string; body: unknown };
2441+
const captured: Captured[] = [];
2442+
const fetchImpl = makeFetch((url, init) => {
2443+
const method = init.method ?? 'GET';
2444+
captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined });
2445+
return { body: BATCH_FRESH_RESP };
2446+
});
2447+
const test = createTestCommand({
2448+
credentialsPath,
2449+
env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv,
2450+
fetchImpl,
2451+
stdout: () => undefined,
2452+
stderr: () => undefined,
2453+
sleep: instantSleep,
2454+
});
2455+
await test.parseAsync(['run', '--all'], { from: 'user' });
2456+
const post = captured.find(c => c.method === 'POST' && c.url.includes('/tests/batch/run'))!;
2457+
expect(post.body).toMatchObject({ projectId: 'project_env', source: 'cli' });
2458+
});
24372459
it('--all --target-url → exit 5 (target-url has no effect on BE-only batch)', async () => {
24382460
const { createTestCommand } = await import('./test.js');
24392461
const test = createTestCommand();

src/commands/test.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,45 @@ describe('runList', () => {
351351
expect(seen[0]).toContain('createdFrom=portal');
352352
});
353353

354+
it('uses TESTSPRITE_PROJECT_ID when --project is omitted', async () => {
355+
const { credentialsPath } = makeCreds();
356+
const seen: string[] = [];
357+
const fetchImpl = makeFetch(url => {
358+
seen.push(url);
359+
return { body: { items: [FE_TEST], nextToken: null } };
360+
});
361+
await runList(
362+
{ profile: 'default', output: 'json', debug: false },
363+
{
364+
credentialsPath,
365+
env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv,
366+
fetchImpl,
367+
stdout: () => undefined,
368+
},
369+
);
370+
expect(seen[0]).toContain('projectId=project_env');
371+
});
372+
373+
it('prefers explicit --project over TESTSPRITE_PROJECT_ID', async () => {
374+
const { credentialsPath } = makeCreds();
375+
const seen: string[] = [];
376+
const fetchImpl = makeFetch(url => {
377+
seen.push(url);
378+
return { body: { items: [FE_TEST], nextToken: null } };
379+
});
380+
await runList(
381+
{ profile: 'default', output: 'json', debug: false, projectId: 'project_flag' },
382+
{
383+
credentialsPath,
384+
env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv,
385+
fetchImpl,
386+
stdout: () => undefined,
387+
},
388+
);
389+
expect(seen[0]).toContain('projectId=project_flag');
390+
expect(seen[0]).not.toContain('projectId=project_env');
391+
});
392+
354393
it('accepts --created-from cli and passes createdFrom=cli to the wire (dogfood 2026-06-04)', async () => {
355394
// End-to-end through parseEnumFlag: backend now stamps createFrom='cli'
356395
// on `testsprite test create` rows, so the filter must accept 'cli'.
@@ -4065,6 +4104,39 @@ describe('runCreate', () => {
40654104
expect(sent.headers.get('x-api-key')).toBe('sk-user-test');
40664105
});
40674106

4107+
it('uses TESTSPRITE_PROJECT_ID for create when --project is omitted', async () => {
4108+
const { credentialsPath } = makeCreds();
4109+
const codeFile = writeCodeFile('code body');
4110+
type Captured = { method: string; body: unknown; url: string };
4111+
const captured: Captured[] = [];
4112+
const fetchImpl = makeFetch((url, init) => {
4113+
const method = init.method ?? 'GET';
4114+
captured.push({ url, method, body: init.body ? JSON.parse(init.body as string) : undefined });
4115+
if (method === 'GET') return { status: 200, body: { items: [] } };
4116+
return { status: 200, body: SAMPLE_RESPONSE };
4117+
});
4118+
await runCreate(
4119+
{
4120+
profile: 'default',
4121+
output: 'json',
4122+
debug: false,
4123+
type: 'frontend',
4124+
name: 'n',
4125+
codeFile,
4126+
},
4127+
{
4128+
credentialsPath,
4129+
env: { TESTSPRITE_PROJECT_ID: 'project_env' } as NodeJS.ProcessEnv,
4130+
fetchImpl,
4131+
stdout: () => undefined,
4132+
},
4133+
);
4134+
expect(captured.some(c => c.method === 'GET' && c.url.includes('projectId=project_env'))).toBe(
4135+
true,
4136+
);
4137+
const post = captured.find(c => c.method === 'POST')!;
4138+
expect(post.body).toMatchObject({ projectId: 'project_env' });
4139+
});
40684140
it('respects a caller-supplied --idempotency-key (for safe retries)', async () => {
40694141
const { credentialsPath } = makeCreds();
40704142
const codeFile = writeCodeFile('code body');
@@ -4218,7 +4290,6 @@ describe('runCreate', () => {
42184290
profile: 'default',
42194291
output: 'json',
42204292
debug: false,
4221-
// @ts-expect-error — exercising the runtime gate
42224293
projectId: undefined,
42234294
type: 'frontend',
42244295
name: 'n',

src/commands/test.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ export interface TestDeps {
381381
type CommonOptions = FactoryCommonOptions;
382382

383383
interface ListOptions extends CommonOptions {
384-
projectId: string;
384+
projectId?: string;
385385
type?: 'frontend' | 'backend';
386386
createdFrom?: 'portal' | 'mcp' | 'cli';
387387
/**
@@ -444,7 +444,8 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
444444
// (exit 3) when the caller also lacks a configured key. Order matters
445445
// for the CLI error spec §2 — bad input is a caller bug, not an auth
446446
// gate.
447-
requireProjectId(opts.projectId);
447+
const projectId = resolveProjectId(opts.projectId, deps);
448+
requireProjectId(projectId);
448449

449450
const paginationFlags: PaginationFlags = validatePaginationFlags({
450451
pageSize: opts.pageSize,
@@ -467,7 +468,7 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
467468
validateStatusFilter(opts.status);
468469

469470
const baseQuery: Record<string, string | number | boolean | undefined> = {
470-
projectId: opts.projectId,
471+
projectId,
471472
type: opts.type,
472473
createdFrom: opts.createdFrom,
473474
status: opts.status,
@@ -523,7 +524,7 @@ export type CliCreatePriority = (typeof CLI_CREATE_PRIORITIES)[number];
523524
const MAX_INLINE_CODE_BYTES = 350 * 1024;
524525

525526
interface CreateOptions extends CommonOptions {
526-
projectId: string;
527+
projectId?: string;
527528
type: 'frontend' | 'backend';
528529
name: string;
529530
description?: string;
@@ -671,7 +672,8 @@ export async function runCreate(
671672
assertChainedRunKeyFits(opts.run, opts.idempotencyKey);
672673
// Validate inputs before touching credentials or fs — matches the
673674
// M2 read commands' "input gates first, then auth, then I/O" ordering.
674-
requireProjectId(opts.projectId);
675+
const projectId = resolveProjectId(opts.projectId, deps);
676+
requireProjectId(projectId);
675677
requireNonEmpty('name', opts.name);
676678
// P1-3: client-side length checks matching server limits (name ≤200,
677679
// description ≤2000) so the user gets instant, actionable errors instead
@@ -747,7 +749,7 @@ export async function runCreate(
747749
}
748750

749751
const body: Record<string, unknown> = {
750-
projectId: opts.projectId,
752+
projectId,
751753
type: opts.type,
752754
name: opts.name,
753755
description: opts.description,
@@ -791,7 +793,7 @@ export async function runCreate(
791793
// B3: best-effort duplicate-name advisory. Skip under --dry-run.
792794
if (!opts.dryRun) {
793795
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
794-
await emitDupNameAdvisoryIfNeeded(client, opts.projectId, opts.name, stderrFn);
796+
await emitDupNameAdvisoryIfNeeded(client, projectId, opts.name, stderrFn);
795797
}
796798

797799
const response = await client.post<CliCreateTestResponse>('/tests', {
@@ -811,7 +813,7 @@ export async function runCreate(
811813
// R1: suppress under --dry-run (fake canned test id).
812814
const chainDashboardUrl = opts.dryRun
813815
? undefined
814-
: resolvePortalUrl(resolveApiUrl(opts, deps), opts.projectId, response.testId);
816+
: resolvePortalUrl(resolveApiUrl(opts, deps), projectId, response.testId);
815817
const createContextWithUrl =
816818
chainDashboardUrl !== undefined ? { ...response, dashboardUrl: chainDashboardUrl } : response;
817819
await runTestRun(
@@ -841,7 +843,7 @@ export async function runCreate(
841843
// (e.g. "test_dryrun_create_2026") and a live-looking URL would mislead.
842844
const dashboardUrl = opts.dryRun
843845
? undefined
844-
: resolvePortalUrl(resolveApiUrl(opts, deps), opts.projectId, response.testId);
846+
: resolvePortalUrl(resolveApiUrl(opts, deps), projectId, response.testId);
845847
if (opts.output === 'json') {
846848
out.print(dashboardUrl !== undefined ? { ...response, dashboardUrl } : response, data =>
847849
renderCreateText(data as CliCreateTestResponse),
@@ -5060,7 +5062,7 @@ export async function runTestWait(
50605062

50615063
interface RunTestRunAllOptions extends CommonOptions {
50625064
/** projectId to run all BE tests in. */
5063-
projectId: string;
5065+
projectId?: string;
50645066
/** --filter <substr>: only run tests whose name contains this substring (case-insensitive). */
50655067
nameFilter?: string;
50665068
/** --wait: block until terminal or --timeout. */
@@ -5097,7 +5099,8 @@ export async function runTestRunAll(
50975099
deps: TestDeps = {},
50985100
): Promise<BatchRunFreshResponse | undefined> {
50995101
assertIdempotencyKey(opts.idempotencyKey);
5100-
requireProjectId(opts.projectId);
5102+
const projectId = resolveProjectId(opts.projectId, deps);
5103+
requireProjectId(projectId);
51015104
if (
51025105
!Number.isInteger(opts.maxConcurrency) ||
51035106
opts.maxConcurrency < 1 ||
@@ -5121,7 +5124,7 @@ export async function runTestRunAll(
51215124
method: 'POST',
51225125
path: '/api/cli/v1/tests/batch/run',
51235126
body: {
5124-
projectId: opts.projectId,
5127+
projectId,
51255128
testIds: opts.nameFilter ? ['<filtered by --filter>'] : undefined,
51265129
source: 'cli' as const,
51275130
},
@@ -5144,9 +5147,9 @@ export async function runTestRunAll(
51445147
const projectDashboardUrl =
51455148
batchPortalBase === undefined
51465149
? undefined
5147-
: `${batchPortalBase}/dashboard/tests/${encodeURIComponent(opts.projectId)}`;
5150+
: `${batchPortalBase}/dashboard/tests/${encodeURIComponent(projectId)}`;
51485151
const withBatchDashboardUrl = <T extends { testId: string }>(item: T): T => {
5149-
const dashboardUrl = resolvePortalUrl(batchApiUrl, opts.projectId, item.testId);
5152+
const dashboardUrl = resolvePortalUrl(batchApiUrl, projectId, item.testId);
51505153
return dashboardUrl !== undefined ? { ...item, dashboardUrl } : item;
51515154
};
51525155

@@ -5162,7 +5165,7 @@ export async function runTestRunAll(
51625165
const allPage = await paginate<CliTest>(
51635166
async ({ pageSize, cursor }) =>
51645167
client.get<Page<CliTest>>('/tests', {
5165-
query: { projectId: opts.projectId, pageSize, cursor },
5168+
query: { projectId, pageSize, cursor },
51665169
}),
51675170
{},
51685171
);
@@ -5178,7 +5181,7 @@ export async function runTestRunAll(
51785181
testIds = filtered.map(t => t.id);
51795182
if (testIds.length === 0) {
51805183
stderrFn(
5181-
`No tests found in project ${opts.projectId} matching --filter "${opts.nameFilter}" — nothing to run.`,
5184+
`No tests found in project ${projectId} matching --filter "${opts.nameFilter}" — nothing to run.`,
51825185
);
51835186
out.print({
51845187
accepted: [],
@@ -5190,14 +5193,14 @@ export async function runTestRunAll(
51905193
return undefined;
51915194
}
51925195
stderrFn(
5193-
`Resolved ${testIds.length} test${testIds.length !== 1 ? 's' : ''} in project ${opts.projectId} for batch run.`,
5196+
`Resolved ${testIds.length} test${testIds.length !== 1 ? 's' : ''} in project ${projectId} for batch run.`,
51945197
);
51955198
}
51965199
// When no --filter, omit testIds → server runs ALL BE tests in the project.
51975200

51985201
const batchResp = await client.triggerBatchRunFresh(
51995202
{
5200-
projectId: opts.projectId,
5203+
projectId,
52015204
...(testIds !== undefined ? { testIds } : {}),
52025205
source: 'cli',
52035206
},
@@ -5341,7 +5344,7 @@ export async function runTestRunAll(
53415344
try {
53425345
retryResp = await client.triggerBatchRunFresh(
53435346
{
5344-
projectId: opts.projectId,
5347+
projectId,
53455348
testIds: retryIds,
53465349
source: 'cli',
53475350
},
@@ -7465,10 +7468,11 @@ export function createTestCommand(deps: TestDeps = {}): Command {
74657468

74667469
if (isAll) {
74677470
// --all path: wave-ordered fresh batch run.
7468-
if (!cmdOpts.project) {
7471+
const projectId = resolveProjectId(cmdOpts.project, deps);
7472+
if (!projectId) {
74697473
throw localValidationError(
74707474
'project',
7471-
'--all requires a project id pass --project <id>',
7475+
'--all requires a project id - pass --project <id> or set TESTSPRITE_PROJECT_ID',
74727476
);
74737477
}
74747478
// --target-url has no effect on the --all batch path: it is BE-only
@@ -7484,7 +7488,7 @@ export function createTestCommand(deps: TestDeps = {}): Command {
74847488
await runTestRunAll(
74857489
{
74867490
...resolveCommonOptions(command),
7487-
projectId: cmdOpts.project,
7491+
projectId,
74887492
nameFilter: cmdOpts.filter,
74897493
wait: cmdOpts.wait === true,
74907494
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
@@ -7772,7 +7776,13 @@ interface StepsFlagOpts {
77727776
runId?: string;
77737777
}
77747778

7775-
function requireProjectId(projectId: string): void {
7779+
function resolveProjectId(projectId: string | undefined, deps: TestDeps): string | undefined {
7780+
if (projectId !== undefined) return projectId;
7781+
const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID;
7782+
const trimmed = envValue?.trim();
7783+
return trimmed && trimmed.length > 0 ? trimmed : undefined;
7784+
}
7785+
function requireProjectId(projectId: string | undefined): asserts projectId is string {
77767786
if (typeof projectId !== 'string' || projectId.length === 0) {
77777787
throw localValidationError('project', 'is required');
77787788
}

0 commit comments

Comments
 (0)