Skip to content

Commit 68f01d5

Browse files
committed
Merge branch 'main' of https://github.com/powxenv/testsprite-cli into chore/agentskills-standard
2 parents aed1e5f + fe07bc9 commit 68f01d5

17 files changed

Lines changed: 1624 additions & 110 deletions

DOCUMENTATION.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ testsprite test create --plan-from ./checkout.plan.json --dry-run --output json
309309

310310
#### `testsprite test create-batch`
311311

312-
Bulk-create frontend tests from a JSONL plan-steps file (or a directory of plan files with `--plan-from-dir`). Optional `--run --max-concurrency <N>` fans out triggers.
312+
Bulk-create frontend tests from a JSONL plan-steps file (or a directory of plan files with `--plan-from-dir`). Optional `--run --max-concurrency <N>` fans out triggers. Without `--wait`, each run is dispatched (`status: "queued"`) and the command exits 0 when every trigger is accepted — mirroring single `test run` without `--wait`; a trigger error still exits non-zero. With `--wait`, it polls every run to terminal and exits non-zero if any run does not pass.
313313

314314
```bash
315315
testsprite test create-batch --plans ./plans.jsonl --run --max-concurrency 4 --output json
@@ -599,24 +599,25 @@ These apply to every command:
599599

600600
### Environment variables
601601

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

614615
### Update notice
615616

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

632633
### 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.create-batch-run.spec.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,225 @@ describe('runCreateBatch --run --wait: mixed outcomes', () => {
333333
});
334334
});
335335

336+
// ---------------------------------------------------------------------------
337+
// No --wait: exit-code contract (issue #161)
338+
//
339+
// Without --wait, every trigger response is non-terminal ('queued') by design.
340+
// A fully successful dispatch must exit 0 — success means every trigger was
341+
// dispatched without error, mirroring single `test run` (no --wait). A trigger
342+
// error must still exit non-zero. Existing no-wait specs only exercised error
343+
// scenarios and never asserted the success exit code, which is how the
344+
// "always exits 1 even when every trigger succeeds" bug slipped through.
345+
// ---------------------------------------------------------------------------
346+
347+
describe('runCreateBatch --run (no --wait): exit-code contract', () => {
348+
let logSpy: ReturnType<typeof vi.spyOn>;
349+
let errorSpy: ReturnType<typeof vi.spyOn>;
350+
351+
beforeEach(() => {
352+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
353+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
354+
});
355+
356+
afterEach(() => {
357+
logSpy.mockRestore();
358+
errorSpy.mockRestore();
359+
});
360+
361+
it('all triggers succeed (queued) → resolves without error, exit 0; results all queued, no error (json)', async () => {
362+
const { credentialsPath } = makeCreds();
363+
const testIds = ['test_q1', 'test_q2', 'test_q3'];
364+
const plansFile = writePlansJsonl([FE_SPEC, FE_SPEC, FE_SPEC]);
365+
366+
let pollCount = 0;
367+
const fetchImpl = makeFetch(url => {
368+
if (url.includes('/tests/batch')) {
369+
return { body: makeBatchCreateResponse(testIds) };
370+
}
371+
const triggerMatch = /\/tests\/(test_[a-z0-9]+)\/runs$/.exec(url);
372+
if (triggerMatch?.[1]) {
373+
const testId = triggerMatch[1];
374+
return { body: makeTriggerResponse(testId, `run_${testId}`) };
375+
}
376+
// No --wait must NOT poll — count any GET /runs as a violation.
377+
if (/\/runs\/run_test_[a-z0-9]+/.exec(url)) {
378+
pollCount++;
379+
}
380+
return {
381+
status: 404,
382+
body: {
383+
error: { code: 'NOT_FOUND', message: 'not found', nextAction: '', requestId: 'r1' },
384+
},
385+
};
386+
});
387+
388+
const stdout: string[] = [];
389+
const stderrLines: string[] = [];
390+
391+
// Must NOT throw — a fully successful no-wait dispatch exits 0.
392+
// (If runBatchRun threw a CLIError, this await would reject and fail the test.)
393+
await runCreateBatch(
394+
{
395+
profile: 'default',
396+
output: 'json',
397+
debug: false,
398+
dryRun: false,
399+
plans: plansFile,
400+
run: true,
401+
wait: false,
402+
timeoutSeconds: 60,
403+
},
404+
{
405+
credentialsPath,
406+
fetchImpl,
407+
stdout: line => stdout.push(line),
408+
stderr: line => stderrLines.push(line),
409+
sleep: instantSleep,
410+
},
411+
);
412+
413+
expect(pollCount).toBe(0); // no polling without --wait
414+
415+
const printed = JSON.parse(stdout.join('')) as {
416+
results: Array<{ testId: string; status: string; error?: unknown }>;
417+
};
418+
expect(printed.results).toHaveLength(3);
419+
expect(printed.results.every(r => r.status === 'queued')).toBe(true);
420+
expect(printed.results.every(r => r.error === undefined)).toBe(true);
421+
});
422+
423+
it('all triggers succeed (queued) → text summary reports "3/3 triggered", exit 0 (no "0/N passed")', async () => {
424+
const { credentialsPath } = makeCreds();
425+
const testIds = ['test_t1', 'test_t2', 'test_t3'];
426+
const plansFile = writePlansJsonl([FE_SPEC, FE_SPEC, FE_SPEC]);
427+
428+
const fetchImpl = makeFetch(url => {
429+
if (url.includes('/tests/batch')) {
430+
return { body: makeBatchCreateResponse(testIds) };
431+
}
432+
const triggerMatch = /\/tests\/(test_[a-z0-9]+)\/runs$/.exec(url);
433+
if (triggerMatch?.[1]) {
434+
const testId = triggerMatch[1];
435+
return { body: makeTriggerResponse(testId, `run_${testId}`) };
436+
}
437+
return {
438+
status: 404,
439+
body: {
440+
error: { code: 'NOT_FOUND', message: 'not found', nextAction: '', requestId: 'r1' },
441+
},
442+
};
443+
});
444+
445+
const stdout: string[] = [];
446+
const stderrLines: string[] = [];
447+
448+
// Must NOT throw — a fully successful no-wait dispatch exits 0.
449+
await runCreateBatch(
450+
{
451+
profile: 'default',
452+
output: 'text',
453+
debug: false,
454+
dryRun: false,
455+
plans: plansFile,
456+
run: true,
457+
wait: false,
458+
timeoutSeconds: 60,
459+
},
460+
{
461+
credentialsPath,
462+
fetchImpl,
463+
stdout: line => stdout.push(line),
464+
stderr: line => stderrLines.push(line),
465+
sleep: instantSleep,
466+
},
467+
);
468+
469+
const summary = stderrLines.find(l => l.startsWith('batch-run summary:'));
470+
expect(summary).toBeDefined();
471+
expect(summary).toContain('3/3 triggered');
472+
// The pre-fix bug printed a pass/fail summary ("0/3 passed") for a fully
473+
// successful no-wait dispatch — assert that misleading wording is gone.
474+
expect(summary).not.toContain('passed');
475+
expect(stderrLines.some(l => l.includes('did not pass'))).toBe(false);
476+
});
477+
478+
it('partial trigger failure (2 queued, 1 errors) → still exits non-zero; all 3 results in envelope', async () => {
479+
const { credentialsPath } = makeCreds();
480+
const testIds = ['test_p1', 'test_p2', 'test_p3'];
481+
const plansFile = writePlansJsonl([FE_SPEC, FE_SPEC, FE_SPEC]);
482+
483+
// test_p3's trigger returns 404 NOT_FOUND — a non-retryable error (exit 4)
484+
// that surfaces immediately as an error result. The other two dispatch fine.
485+
const fetchImpl = makeFetch(url => {
486+
if (url.includes('/tests/batch')) {
487+
return { body: makeBatchCreateResponse(testIds) };
488+
}
489+
const triggerMatch = /\/tests\/(test_[a-z0-9]+)\/runs$/.exec(url);
490+
if (triggerMatch?.[1]) {
491+
const testId = triggerMatch[1];
492+
if (testId === 'test_p3') {
493+
return {
494+
status: 404,
495+
body: {
496+
error: {
497+
code: 'NOT_FOUND',
498+
message: 'test not found',
499+
nextAction: '',
500+
requestId: 'req_p3',
501+
},
502+
},
503+
};
504+
}
505+
return { body: makeTriggerResponse(testId, `run_${testId}`) };
506+
}
507+
return {
508+
status: 404,
509+
body: {
510+
error: { code: 'NOT_FOUND', message: 'not found', nextAction: '', requestId: 'r1' },
511+
},
512+
};
513+
});
514+
515+
const stdout: string[] = [];
516+
const stderrLines: string[] = [];
517+
518+
const err = await runCreateBatch(
519+
{
520+
profile: 'default',
521+
output: 'json',
522+
debug: false,
523+
dryRun: false,
524+
plans: plansFile,
525+
run: true,
526+
wait: false,
527+
timeoutSeconds: 60,
528+
},
529+
{
530+
credentialsPath,
531+
fetchImpl,
532+
stdout: line => stdout.push(line),
533+
stderr: line => stderrLines.push(line),
534+
sleep: instantSleep,
535+
},
536+
).catch(e => e);
537+
538+
// A dispatch with any trigger error must exit non-zero.
539+
expect(err).toBeInstanceOf(CLIError);
540+
expect((err as CLIError).exitCode).not.toBe(0);
541+
542+
const printed = JSON.parse(stdout.join('')) as {
543+
results: Array<{ testId: string; status: string; error?: { code: string } }>;
544+
};
545+
expect(printed.results).toHaveLength(3);
546+
const queued = printed.results.filter(r => r.status === 'queued');
547+
const errored = printed.results.filter(r => r.error !== undefined);
548+
expect(queued).toHaveLength(2);
549+
expect(errored).toHaveLength(1);
550+
expect(errored[0]?.testId).toBe('test_p3');
551+
expect(errored[0]?.error?.code).toBe('NOT_FOUND');
552+
});
553+
});
554+
336555
// ---------------------------------------------------------------------------
337556
// --max-concurrency: verify only N in-flight at any time
338557
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)