Skip to content

Commit 8db1882

Browse files
authored
feat(test): add "test open <test-id>" to jump from the terminal to the dashboard (#226)
* feat(test): add "test open <test-id>" to jump from the terminal to the dashboard * fix(open): derive the dry-run URL via resolvePortalUrl and handle async spawn ENOENT * fix(browser): map non-http(s) URL rejection to exit 5 (validation error) openInBrowser threw a plain Error for a non-http(s) URL, which fell through the top-level handler and exited 1. Route it through localValidationError so it is classified as VALIDATION_ERROR and maps to the documented exit code 5, matching every other bad-argument path. Test asserts the exit code, not the message string.
1 parent eaf0585 commit 8db1882

5 files changed

Lines changed: 337 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
runGet,
3434
runLint,
3535
runList,
36+
runOpen,
3637
runPlanPut,
3738
runResult,
3839
runScaffold,
@@ -132,6 +133,7 @@ describe('createTestCommand — surface', () => {
132133
'get',
133134
'lint',
134135
'list',
136+
'open',
135137
'plan',
136138
'rerun',
137139
'result',
@@ -2450,6 +2452,94 @@ describe('runScaffold', () => {
24502452
});
24512453
});
24522454

2455+
describe('runOpen', () => {
2456+
// The mock endpoint host has no portal mapping; the operator override is the
2457+
// supported escape hatch and gives the tests a deterministic base.
2458+
beforeEach(() => {
2459+
process.env.TESTSPRITE_PORTAL_URL = 'https://portal.example.com';
2460+
});
2461+
afterEach(() => {
2462+
delete process.env.TESTSPRITE_PORTAL_URL;
2463+
});
2464+
2465+
const TEST_ROW = {
2466+
id: 'test_open_me',
2467+
projectId: 'project_alice',
2468+
projectName: 'Alice',
2469+
name: 'Checkout',
2470+
type: 'frontend',
2471+
createdFrom: 'cli',
2472+
status: 'ready',
2473+
createdAt: '2026-06-01T10:00:00.000Z',
2474+
updatedAt: '2026-06-01T10:00:00.000Z',
2475+
};
2476+
2477+
it('prints the dashboard URL and spawns the opener with it', async () => {
2478+
const { credentialsPath } = makeCreds();
2479+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2480+
const out: string[] = [];
2481+
const opened: string[] = [];
2482+
const result = await runOpen(
2483+
{
2484+
profile: 'default',
2485+
output: 'text',
2486+
debug: false,
2487+
testId: 'test_open_me',
2488+
noBrowser: false,
2489+
},
2490+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2491+
url => opened.push(url),
2492+
);
2493+
expect(result.dashboardUrl).toContain('/dashboard/tests/project_alice/test/test_open_me');
2494+
expect(out.join('\n')).toContain(result.dashboardUrl);
2495+
expect(opened).toEqual([result.dashboardUrl]);
2496+
});
2497+
2498+
it('--no-browser prints the URL but never spawns', async () => {
2499+
const { credentialsPath } = makeCreds();
2500+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2501+
const out: string[] = [];
2502+
const opened: string[] = [];
2503+
await runOpen(
2504+
{
2505+
profile: 'default',
2506+
output: 'json',
2507+
debug: false,
2508+
testId: 'test_open_me',
2509+
noBrowser: true,
2510+
},
2511+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2512+
url => opened.push(url),
2513+
);
2514+
expect(opened).toEqual([]);
2515+
expect((JSON.parse(out.join('')) as { dashboardUrl: string }).dashboardUrl).toContain(
2516+
'test_open_me',
2517+
);
2518+
});
2519+
2520+
it('a broken opener downgrades to a stderr hint, never a failure', async () => {
2521+
const { credentialsPath } = makeCreds();
2522+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2523+
const errs: string[] = [];
2524+
await expect(
2525+
runOpen(
2526+
{
2527+
profile: 'default',
2528+
output: 'text',
2529+
debug: false,
2530+
testId: 'test_open_me',
2531+
noBrowser: false,
2532+
},
2533+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2534+
() => {
2535+
throw new Error('no display');
2536+
},
2537+
),
2538+
).resolves.toBeDefined();
2539+
expect(errs.join('\n')).toContain('could not launch a browser');
2540+
});
2541+
});
2542+
24532543
describe('runSteps', () => {
24542544
it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => {
24552545
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { rename, stat, unlink } from 'node:fs/promises';
1010
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
1111
import { randomUUID } from 'node:crypto';
1212
import { Command } from 'commander';
13+
import { openInBrowser } from '../lib/browser.js';
1314
import {
1415
emitDryRunBanner,
1516
makeHttpClient,
@@ -4276,6 +4277,67 @@ export async function runScaffold(
42764277
return payload;
42774278
}
42784279

4280+
export interface OpenOptions extends CommonOptions {
4281+
testId: string;
4282+
/** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
4283+
noBrowser: boolean;
4284+
}
4285+
4286+
/**
4287+
* `test open <test-id>` (issue #121): jump from the terminal to the test's
4288+
* dashboard page. The CLI already computes this deep-link and prints it as
4289+
* text on other commands; this closes the last inch (the `gh browse` /
4290+
* `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
4291+
* and headless use still compose), then the OS browser is spawned unless
4292+
* --no-browser. An endpoint with no known portal mapping is a hard error
4293+
* rather than a silent no-op.
4294+
*/
4295+
export async function runOpen(
4296+
opts: OpenOptions,
4297+
deps: TestDeps = {},
4298+
opener: (url: string) => void = openInBrowser,
4299+
): Promise<{ dashboardUrl: string }> {
4300+
const out = makeOutput(opts.output, deps);
4301+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4302+
4303+
if (opts.dryRun) {
4304+
emitDryRunBanner(stderrFn);
4305+
// Derive the sample through the SAME resolver as the live path (against
4306+
// the canonical prod endpoint and the dry-run project id) so the two can
4307+
// never drift; the ?? arm is unreachable for the prod mapping but keeps
4308+
// the type total.
4309+
const sample = {
4310+
dashboardUrl:
4311+
resolvePortalUrl('https://api.testsprite.com', 'p_dryrun_2026', opts.testId) ??
4312+
`https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${encodeURIComponent(opts.testId)}`,
4313+
};
4314+
out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl);
4315+
return sample;
4316+
}
4317+
4318+
const client = makeClient(opts, deps);
4319+
// The deep-link needs the projectId; the test record is the source of truth.
4320+
const test = await client.get<CliTest>(`/tests/${encodeURIComponent(opts.testId)}`);
4321+
const dashboardUrl = resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId);
4322+
if (dashboardUrl === undefined) {
4323+
throw new CLIError(
4324+
`no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin`,
4325+
1,
4326+
);
4327+
}
4328+
out.print({ dashboardUrl }, () => dashboardUrl);
4329+
if (!opts.noBrowser) {
4330+
try {
4331+
opener(dashboardUrl);
4332+
} catch {
4333+
// The URL is already on stdout; a missing opener (containers, minimal
4334+
// hosts) downgrades to "open it yourself" instead of a hard failure.
4335+
stderrFn('could not launch a browser; open the URL above manually (or use --no-browser)');
4336+
}
4337+
}
4338+
return { dashboardUrl };
4339+
}
4340+
42794341
export async function runSteps(
42804342
opts: StepsOptions,
42814343
deps: TestDeps = {},
@@ -8720,6 +8782,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
87208782
);
87218783
});
87228784

8785+
test
8786+
.command('open <test-id>')
8787+
.description(
8788+
'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.',
8789+
)
8790+
.option('--no-browser', 'print the URL only (SSH, headless, CI, agents)')
8791+
.addHelpText('after', GLOBAL_OPTS_HINT)
8792+
.action(async (testId: string, cmdOpts: { browser?: boolean }, command: Command) => {
8793+
await runOpen(
8794+
{
8795+
...resolveCommonOptions(command),
8796+
testId,
8797+
noBrowser: cmdOpts.browser === false,
8798+
},
8799+
deps,
8800+
);
8801+
});
8802+
87238803
test
87248804
.command('steps <test-id>')
87258805
.description(

src/lib/browser.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Mock the real spawner so the default `exec` path (detached spawn + unref)
4+
// is exercised without launching a real process.
5+
const spawnMock = vi.fn();
6+
vi.mock('node:child_process', () => ({
7+
spawn: (command: string, args: readonly string[], opts: unknown) =>
8+
spawnMock(command, args, opts),
9+
}));
10+
11+
import { openInBrowser } from './browser.js';
12+
import { ApiError } from './errors.js';
13+
14+
describe('openInBrowser', () => {
15+
const url = 'https://portal.example.com/tests/t_123';
16+
17+
it('uses `open <url>` on darwin', () => {
18+
const calls: Array<{ command: string; args: readonly string[] }> = [];
19+
openInBrowser(url, {
20+
platform: 'darwin',
21+
exec: (command, args) => calls.push({ command, args }),
22+
});
23+
expect(calls).toEqual([{ command: 'open', args: [url] }]);
24+
});
25+
26+
it('uses rundll32 FileProtocolHandler on win32', () => {
27+
const calls: Array<{ command: string; args: readonly string[] }> = [];
28+
openInBrowser(url, {
29+
platform: 'win32',
30+
exec: (command, args) => calls.push({ command, args }),
31+
});
32+
expect(calls).toEqual([{ command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }]);
33+
});
34+
35+
it('uses xdg-open on other platforms', () => {
36+
const calls: Array<{ command: string; args: readonly string[] }> = [];
37+
openInBrowser(url, {
38+
platform: 'linux',
39+
exec: (command, args) => calls.push({ command, args }),
40+
});
41+
expect(calls).toEqual([{ command: 'xdg-open', args: [url] }]);
42+
});
43+
44+
it('refuses a non-http(s) URL with exit 5 before spawning', () => {
45+
const exec = vi.fn();
46+
let error: unknown;
47+
try {
48+
openInBrowser('file:///etc/passwd', { platform: 'linux', exec });
49+
} catch (err) {
50+
error = err;
51+
}
52+
expect(error).toBeInstanceOf(ApiError);
53+
expect((error as ApiError).exitCode).toBe(5);
54+
expect(exec).not.toHaveBeenCalled();
55+
});
56+
57+
it('throws on a malformed URL', () => {
58+
const exec = vi.fn();
59+
expect(() => openInBrowser('not a url', { exec })).toThrow();
60+
expect(exec).not.toHaveBeenCalled();
61+
});
62+
63+
describe('default spawner', () => {
64+
beforeEach(() => {
65+
spawnMock.mockReset();
66+
});
67+
68+
it('spawns detached, ignores stdio, and unrefs the child', () => {
69+
const unref = vi.fn();
70+
spawnMock.mockReturnValue({ unref, on: vi.fn() });
71+
openInBrowser(url, { platform: 'darwin' });
72+
expect(spawnMock).toHaveBeenCalledWith('open', [url], { detached: true, stdio: 'ignore' });
73+
expect(unref).toHaveBeenCalledTimes(1);
74+
});
75+
76+
it("handles the child's async 'error' (missing binary) with a stderr hint, not a crash", () => {
77+
// spawn() reports ENOENT asynchronously on the child; an unhandled
78+
// 'error' event would crash the CLI. The default spawner must register
79+
// a listener that degrades to the manual-open hint.
80+
const listeners = new Map<string, (err: Error) => void>();
81+
spawnMock.mockReturnValue({
82+
unref: vi.fn(),
83+
on: (event: string, listener: (err: Error) => void) => {
84+
listeners.set(event, listener);
85+
},
86+
});
87+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
88+
try {
89+
openInBrowser(url, { platform: 'linux' });
90+
const onError = listeners.get('error');
91+
expect(onError).toBeDefined();
92+
// Firing the listener must not throw and must print the hint.
93+
expect(() => onError!(new Error('spawn xdg-open ENOENT'))).not.toThrow();
94+
expect(String(stderrSpy.mock.calls.at(-1)?.[0])).toContain('could not launch a browser');
95+
} finally {
96+
stderrSpy.mockRestore();
97+
}
98+
});
99+
});
100+
});

src/lib/browser.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Cross-platform "open this URL in the default browser" helper for
3+
* `test open` (issue #121). Spawns the platform opener with an argv array
4+
* (never a shell string) so a URL can never be shell-injected, and refuses
5+
* anything that is not http(s) before any process is spawned.
6+
*
7+
* Platform openers:
8+
* darwin open <url>
9+
* win32 rundll32 url.dll,FileProtocolHandler <url> (avoids `cmd /c start`,
10+
* whose re-parsing would mangle `&` and other metachars in the URL)
11+
* other xdg-open <url>
12+
*
13+
* The child is detached and unref'd so the CLI exits immediately; failures
14+
* are the caller's to surface (it already printed the URL as the fallback).
15+
*/
16+
import { spawn } from 'node:child_process';
17+
import { localValidationError } from './errors.js';
18+
19+
export interface OpenInBrowserDeps {
20+
platform?: NodeJS.Platform;
21+
/** Process spawner taking an argv array. Defaults to a detached spawn. */
22+
exec?: (command: string, args: readonly string[]) => void;
23+
}
24+
25+
export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): void {
26+
const parsed = new URL(url);
27+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
28+
// User-input error, not an internal failure: classify as VALIDATION_ERROR
29+
// so it maps to exit 5 (like every other bad-argument path), not exit 1.
30+
throw localValidationError(
31+
'url',
32+
`must be an http(s) URL (got ${parsed.protocol})`,
33+
undefined,
34+
'field',
35+
);
36+
}
37+
const platform = deps.platform ?? process.platform;
38+
const exec =
39+
deps.exec ??
40+
((command: string, args: readonly string[]) => {
41+
const child = spawn(command, [...args], { detached: true, stdio: 'ignore' });
42+
// spawn() reports a missing binary (ENOENT) ASYNCHRONOUSLY on the child,
43+
// so the caller's try/catch cannot see it — and an unhandled 'error'
44+
// event would crash the whole CLI. The URL is already on stdout, so
45+
// degrade to the same manual-open hint the sync failure path prints.
46+
child.on('error', () => {
47+
process.stderr.write(
48+
'could not launch a browser; open the URL above manually (or use --no-browser)\n',
49+
);
50+
});
51+
child.unref();
52+
});
53+
54+
if (platform === 'darwin') {
55+
exec('open', [url]);
56+
return;
57+
}
58+
if (platform === 'win32') {
59+
exec('rundll32', ['url.dll,FileProtocolHandler', url]);
60+
return;
61+
}
62+
exec('xdg-open', [url]);
63+
}

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ Commands:
222222
definition (frontend plan JSON by
223223
default, or a backend Python skeleton).
224224
Pure-local: no network, no credentials.
225+
open [options] <test-id> Open the test in the TestSprite
226+
dashboard: prints the deep-link URL,
227+
then spawns your default browser unless
228+
--no-browser.
225229
steps [options] <test-id> List the steps for a test (server
226230
returns the cumulative log across every
227231
run; use --run-id to scope to one run)

0 commit comments

Comments
 (0)