Skip to content

Commit fc44d5b

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

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
@@ -32,6 +32,7 @@ import {
3232
runFailureSummary,
3333
runGet,
3434
runList,
35+
runOpen,
3536
runPlanPut,
3637
runResult,
3738
runSteps,
@@ -126,6 +127,7 @@ describe('createTestCommand — surface', () => {
126127
'failure',
127128
'get',
128129
'list',
130+
'open',
129131
'plan',
130132
'rerun',
131133
'result',
@@ -2321,6 +2323,94 @@ describe('runCodePut', () => {
23212323
});
23222324
});
23232325

2326+
describe('runOpen', () => {
2327+
// The mock endpoint host has no portal mapping; the operator override is the
2328+
// supported escape hatch and gives the tests a deterministic base.
2329+
beforeEach(() => {
2330+
process.env.TESTSPRITE_PORTAL_URL = 'https://portal.example.com';
2331+
});
2332+
afterEach(() => {
2333+
delete process.env.TESTSPRITE_PORTAL_URL;
2334+
});
2335+
2336+
const TEST_ROW = {
2337+
id: 'test_open_me',
2338+
projectId: 'project_alice',
2339+
projectName: 'Alice',
2340+
name: 'Checkout',
2341+
type: 'frontend',
2342+
createdFrom: 'cli',
2343+
status: 'ready',
2344+
createdAt: '2026-06-01T10:00:00.000Z',
2345+
updatedAt: '2026-06-01T10:00:00.000Z',
2346+
};
2347+
2348+
it('prints the dashboard URL and spawns the opener with it', async () => {
2349+
const { credentialsPath } = makeCreds();
2350+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2351+
const out: string[] = [];
2352+
const opened: string[] = [];
2353+
const result = await runOpen(
2354+
{
2355+
profile: 'default',
2356+
output: 'text',
2357+
debug: false,
2358+
testId: 'test_open_me',
2359+
noBrowser: false,
2360+
},
2361+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2362+
url => opened.push(url),
2363+
);
2364+
expect(result.dashboardUrl).toContain('/dashboard/tests/project_alice/test/test_open_me');
2365+
expect(out.join('\n')).toContain(result.dashboardUrl);
2366+
expect(opened).toEqual([result.dashboardUrl]);
2367+
});
2368+
2369+
it('--no-browser prints the URL but never spawns', async () => {
2370+
const { credentialsPath } = makeCreds();
2371+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2372+
const out: string[] = [];
2373+
const opened: string[] = [];
2374+
await runOpen(
2375+
{
2376+
profile: 'default',
2377+
output: 'json',
2378+
debug: false,
2379+
testId: 'test_open_me',
2380+
noBrowser: true,
2381+
},
2382+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2383+
url => opened.push(url),
2384+
);
2385+
expect(opened).toEqual([]);
2386+
expect((JSON.parse(out.join('')) as { dashboardUrl: string }).dashboardUrl).toContain(
2387+
'test_open_me',
2388+
);
2389+
});
2390+
2391+
it('a broken opener downgrades to a stderr hint, never a failure', async () => {
2392+
const { credentialsPath } = makeCreds();
2393+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2394+
const errs: string[] = [];
2395+
await expect(
2396+
runOpen(
2397+
{
2398+
profile: 'default',
2399+
output: 'text',
2400+
debug: false,
2401+
testId: 'test_open_me',
2402+
noBrowser: false,
2403+
},
2404+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2405+
() => {
2406+
throw new Error('no display');
2407+
},
2408+
),
2409+
).resolves.toBeDefined();
2410+
expect(errs.join('\n')).toContain('could not launch a browser');
2411+
});
2412+
});
2413+
23242414
describe('runSteps', () => {
23252415
it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => {
23262416
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,
@@ -3854,6 +3855,61 @@ function renderRunDiffText(diff: CliRunDiff): string {
38543855
return lines.join('\n');
38553856
}
38563857

3858+
export interface OpenOptions extends CommonOptions {
3859+
testId: string;
3860+
/** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
3861+
noBrowser: boolean;
3862+
}
3863+
3864+
/**
3865+
* `test open <test-id>` (issue #121): jump from the terminal to the test's
3866+
* dashboard page. The CLI already computes this deep-link and prints it as
3867+
* text on other commands; this closes the last inch (the `gh browse` /
3868+
* `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
3869+
* and headless use still compose), then the OS browser is spawned unless
3870+
* --no-browser. An endpoint with no known portal mapping is a hard error
3871+
* rather than a silent no-op.
3872+
*/
3873+
export async function runOpen(
3874+
opts: OpenOptions,
3875+
deps: TestDeps = {},
3876+
opener: (url: string) => void = openInBrowser,
3877+
): Promise<{ dashboardUrl: string }> {
3878+
const out = makeOutput(opts.output, deps);
3879+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
3880+
3881+
if (opts.dryRun) {
3882+
emitDryRunBanner(stderrFn);
3883+
const sample = {
3884+
dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`,
3885+
};
3886+
out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl);
3887+
return sample;
3888+
}
3889+
3890+
const client = makeClient(opts, deps);
3891+
// The deep-link needs the projectId; the test record is the source of truth.
3892+
const test = await client.get<CliTest>(`/tests/${encodeURIComponent(opts.testId)}`);
3893+
const dashboardUrl = resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId);
3894+
if (dashboardUrl === undefined) {
3895+
throw new CLIError(
3896+
`no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin`,
3897+
1,
3898+
);
3899+
}
3900+
out.print({ dashboardUrl }, () => dashboardUrl);
3901+
if (!opts.noBrowser) {
3902+
try {
3903+
opener(dashboardUrl);
3904+
} catch {
3905+
// The URL is already on stdout; a missing opener (containers, minimal
3906+
// hosts) downgrades to "open it yourself" instead of a hard failure.
3907+
stderrFn('could not launch a browser; open the URL above manually (or use --no-browser)');
3908+
}
3909+
}
3910+
return { dashboardUrl };
3911+
}
3912+
38573913
export async function runSteps(
38583914
opts: StepsOptions,
38593915
deps: TestDeps = {},
@@ -7624,6 +7680,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
76247680
);
76257681
});
76267682

7683+
test
7684+
.command('open <test-id>')
7685+
.description(
7686+
'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.',
7687+
)
7688+
.option('--no-browser', 'print the URL only (SSH, headless, CI, agents)')
7689+
.addHelpText('after', GLOBAL_OPTS_HINT)
7690+
.action(async (testId: string, cmdOpts: { browser?: boolean }, command: Command) => {
7691+
await runOpen(
7692+
{
7693+
...resolveCommonOptions(command),
7694+
testId,
7695+
noBrowser: cmdOpts.browser === false,
7696+
},
7697+
deps,
7698+
);
7699+
});
7700+
76277701
test
76287702
.command('steps <test-id>')
76297703
.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
@@ -200,6 +200,10 @@ Commands:
200200
(--plan-from, FE-only, M3.2 piece-5)
201201
create-batch [options] Create multiple FE tests from a JSONL
202202
of plan specs (FE-only)
203+
open [options] <test-id> Open the test in the TestSprite
204+
dashboard: prints the deep-link URL,
205+
then spawns your default browser unless
206+
--no-browser.
203207
steps [options] <test-id> List the steps for a test (server
204208
returns the cumulative log across every
205209
run; use --run-id to scope to one run)

0 commit comments

Comments
 (0)