Skip to content

Commit f0d4f94

Browse files
committed
feat(test): add "test open <test-id>" to jump from the terminal to the dashboard
1 parent e53257d commit f0d4f94

5 files changed

Lines changed: 284 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
runFailureSummary,
3232
runGet,
3333
runList,
34+
runOpen,
3435
runPlanPut,
3536
runResult,
3637
runSteps,
@@ -124,6 +125,7 @@ describe('createTestCommand — surface', () => {
124125
'failure',
125126
'get',
126127
'list',
128+
'open',
127129
'plan',
128130
'rerun',
129131
'result',
@@ -2266,6 +2268,94 @@ describe('runCodePut', () => {
22662268
});
22672269
});
22682270

2271+
describe('runOpen', () => {
2272+
// The mock endpoint host has no portal mapping; the operator override is the
2273+
// supported escape hatch and gives the tests a deterministic base.
2274+
beforeEach(() => {
2275+
process.env.TESTSPRITE_PORTAL_URL = 'https://portal.example.com';
2276+
});
2277+
afterEach(() => {
2278+
delete process.env.TESTSPRITE_PORTAL_URL;
2279+
});
2280+
2281+
const TEST_ROW = {
2282+
id: 'test_open_me',
2283+
projectId: 'project_alice',
2284+
projectName: 'Alice',
2285+
name: 'Checkout',
2286+
type: 'frontend',
2287+
createdFrom: 'cli',
2288+
status: 'ready',
2289+
createdAt: '2026-06-01T10:00:00.000Z',
2290+
updatedAt: '2026-06-01T10:00:00.000Z',
2291+
};
2292+
2293+
it('prints the dashboard URL and spawns the opener with it', async () => {
2294+
const { credentialsPath } = makeCreds();
2295+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2296+
const out: string[] = [];
2297+
const opened: string[] = [];
2298+
const result = await runOpen(
2299+
{
2300+
profile: 'default',
2301+
output: 'text',
2302+
debug: false,
2303+
testId: 'test_open_me',
2304+
noBrowser: false,
2305+
},
2306+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2307+
url => opened.push(url),
2308+
);
2309+
expect(result.dashboardUrl).toContain('/dashboard/tests/project_alice/test/test_open_me');
2310+
expect(out.join('\n')).toContain(result.dashboardUrl);
2311+
expect(opened).toEqual([result.dashboardUrl]);
2312+
});
2313+
2314+
it('--no-browser prints the URL but never spawns', async () => {
2315+
const { credentialsPath } = makeCreds();
2316+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2317+
const out: string[] = [];
2318+
const opened: string[] = [];
2319+
await runOpen(
2320+
{
2321+
profile: 'default',
2322+
output: 'json',
2323+
debug: false,
2324+
testId: 'test_open_me',
2325+
noBrowser: true,
2326+
},
2327+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2328+
url => opened.push(url),
2329+
);
2330+
expect(opened).toEqual([]);
2331+
expect((JSON.parse(out.join('')) as { dashboardUrl: string }).dashboardUrl).toContain(
2332+
'test_open_me',
2333+
);
2334+
});
2335+
2336+
it('a broken opener downgrades to a stderr hint, never a failure', async () => {
2337+
const { credentialsPath } = makeCreds();
2338+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2339+
const errs: string[] = [];
2340+
await expect(
2341+
runOpen(
2342+
{
2343+
profile: 'default',
2344+
output: 'text',
2345+
debug: false,
2346+
testId: 'test_open_me',
2347+
noBrowser: false,
2348+
},
2349+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2350+
() => {
2351+
throw new Error('no display');
2352+
},
2353+
),
2354+
).resolves.toBeDefined();
2355+
expect(errs.join('\n')).toContain('could not launch a browser');
2356+
});
2357+
});
2358+
22692359
describe('runSteps', () => {
22702360
it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => {
22712361
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { rename, stat, unlink } from 'node:fs/promises';
33
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
44
import { randomUUID } from 'node:crypto';
55
import { Command } from 'commander';
6+
import { openInBrowser } from '../lib/browser.js';
67
import {
78
emitDryRunBanner,
89
makeHttpClient,
@@ -3630,6 +3631,61 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36303631
};
36313632
}
36323633

3634+
export interface OpenOptions extends CommonOptions {
3635+
testId: string;
3636+
/** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
3637+
noBrowser: boolean;
3638+
}
3639+
3640+
/**
3641+
* `test open <test-id>` (issue #121): jump from the terminal to the test's
3642+
* dashboard page. The CLI already computes this deep-link and prints it as
3643+
* text on other commands; this closes the last inch (the `gh browse` /
3644+
* `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
3645+
* and headless use still compose), then the OS browser is spawned unless
3646+
* --no-browser. An endpoint with no known portal mapping is a hard error
3647+
* rather than a silent no-op.
3648+
*/
3649+
export async function runOpen(
3650+
opts: OpenOptions,
3651+
deps: TestDeps = {},
3652+
opener: (url: string) => void = openInBrowser,
3653+
): Promise<{ dashboardUrl: string }> {
3654+
const out = makeOutput(opts.output, deps);
3655+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
3656+
3657+
if (opts.dryRun) {
3658+
emitDryRunBanner(stderrFn);
3659+
const sample = {
3660+
dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`,
3661+
};
3662+
out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl);
3663+
return sample;
3664+
}
3665+
3666+
const client = makeClient(opts, deps);
3667+
// The deep-link needs the projectId; the test record is the source of truth.
3668+
const test = await client.get<CliTest>(`/tests/${encodeURIComponent(opts.testId)}`);
3669+
const dashboardUrl = resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId);
3670+
if (dashboardUrl === undefined) {
3671+
throw new CLIError(
3672+
`no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin`,
3673+
1,
3674+
);
3675+
}
3676+
out.print({ dashboardUrl }, () => dashboardUrl);
3677+
if (!opts.noBrowser) {
3678+
try {
3679+
opener(dashboardUrl);
3680+
} catch {
3681+
// The URL is already on stdout; a missing opener (containers, minimal
3682+
// hosts) downgrades to "open it yourself" instead of a hard failure.
3683+
stderrFn('could not launch a browser; open the URL above manually (or use --no-browser)');
3684+
}
3685+
}
3686+
return { dashboardUrl };
3687+
}
3688+
36333689
export async function runSteps(
36343690
opts: StepsOptions,
36353691
deps: TestDeps = {},
@@ -7207,6 +7263,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
72077263
);
72087264
});
72097265

7266+
test
7267+
.command('open <test-id>')
7268+
.description(
7269+
'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.',
7270+
)
7271+
.option('--no-browser', 'print the URL only (SSH, headless, CI, agents)')
7272+
.addHelpText('after', GLOBAL_OPTS_HINT)
7273+
.action(async (testId: string, cmdOpts: { browser?: boolean }, command: Command) => {
7274+
await runOpen(
7275+
{
7276+
...resolveCommonOptions(command),
7277+
testId,
7278+
noBrowser: cmdOpts.browser === false,
7279+
},
7280+
deps,
7281+
);
7282+
});
7283+
72107284
test
72117285
.command('steps <test-id>')
72127286
.description(

src/lib/browser.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
13+
describe('openInBrowser', () => {
14+
const url = 'https://portal.example.com/tests/t_123';
15+
16+
it('uses `open <url>` on darwin', () => {
17+
const calls: Array<{ command: string; args: readonly string[] }> = [];
18+
openInBrowser(url, {
19+
platform: 'darwin',
20+
exec: (command, args) => calls.push({ command, args }),
21+
});
22+
expect(calls).toEqual([{ command: 'open', args: [url] }]);
23+
});
24+
25+
it('uses rundll32 FileProtocolHandler on win32', () => {
26+
const calls: Array<{ command: string; args: readonly string[] }> = [];
27+
openInBrowser(url, {
28+
platform: 'win32',
29+
exec: (command, args) => calls.push({ command, args }),
30+
});
31+
expect(calls).toEqual([{ command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }]);
32+
});
33+
34+
it('uses xdg-open on other platforms', () => {
35+
const calls: Array<{ command: string; args: readonly string[] }> = [];
36+
openInBrowser(url, {
37+
platform: 'linux',
38+
exec: (command, args) => calls.push({ command, args }),
39+
});
40+
expect(calls).toEqual([{ command: 'xdg-open', args: [url] }]);
41+
});
42+
43+
it('refuses a non-http(s) URL before spawning', () => {
44+
const exec = vi.fn();
45+
expect(() => openInBrowser('file:///etc/passwd', { platform: 'linux', exec })).toThrow(
46+
/non-http\(s\)/,
47+
);
48+
expect(exec).not.toHaveBeenCalled();
49+
});
50+
51+
it('throws on a malformed URL', () => {
52+
const exec = vi.fn();
53+
expect(() => openInBrowser('not a url', { exec })).toThrow();
54+
expect(exec).not.toHaveBeenCalled();
55+
});
56+
57+
describe('default spawner', () => {
58+
beforeEach(() => {
59+
spawnMock.mockReset();
60+
});
61+
62+
it('spawns detached, ignores stdio, and unrefs the child', () => {
63+
const unref = vi.fn();
64+
spawnMock.mockReturnValue({ unref });
65+
openInBrowser(url, { platform: 'darwin' });
66+
expect(spawnMock).toHaveBeenCalledWith('open', [url], { detached: true, stdio: 'ignore' });
67+
expect(unref).toHaveBeenCalledTimes(1);
68+
});
69+
});
70+
});

src/lib/browser.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
18+
export interface OpenInBrowserDeps {
19+
platform?: NodeJS.Platform;
20+
/** Process spawner taking an argv array. Defaults to a detached spawn. */
21+
exec?: (command: string, args: readonly string[]) => void;
22+
}
23+
24+
export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): void {
25+
const parsed = new URL(url);
26+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
27+
throw new Error(`refusing to open a non-http(s) URL (${parsed.protocol})`);
28+
}
29+
const platform = deps.platform ?? process.platform;
30+
const exec =
31+
deps.exec ??
32+
((command: string, args: readonly string[]) => {
33+
const child = spawn(command, [...args], { detached: true, stdio: 'ignore' });
34+
child.unref();
35+
});
36+
37+
if (platform === 'darwin') {
38+
exec('open', [url]);
39+
return;
40+
}
41+
if (platform === 'win32') {
42+
exec('rundll32', ['url.dll,FileProtocolHandler', url]);
43+
return;
44+
}
45+
exec('xdg-open', [url]);
46+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ Commands:
196196
(--plan-from, FE-only, M3.2 piece-5)
197197
create-batch [options] Create multiple FE tests from a JSONL
198198
of plan specs (FE-only)
199+
open [options] <test-id> Open the test in the TestSprite
200+
dashboard: prints the deep-link URL,
201+
then spawns your default browser unless
202+
--no-browser.
199203
steps [options] <test-id> List the steps for a test (server
200204
returns the cumulative log across every
201205
run; use --run-id to scope to one run)

0 commit comments

Comments
 (0)