Skip to content

Commit 1266618

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

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
@@ -33,6 +33,7 @@ import {
3333
runGet,
3434
runLint,
3535
runList,
36+
runOpen,
3637
runPlanPut,
3738
runResult,
3839
runScaffold,
@@ -131,6 +132,7 @@ describe('createTestCommand — surface', () => {
131132
'get',
132133
'lint',
133134
'list',
135+
'open',
134136
'plan',
135137
'rerun',
136138
'result',
@@ -2415,6 +2417,94 @@ describe('runScaffold', () => {
24152417
});
24162418
});
24172419

2420+
describe('runOpen', () => {
2421+
// The mock endpoint host has no portal mapping; the operator override is the
2422+
// supported escape hatch and gives the tests a deterministic base.
2423+
beforeEach(() => {
2424+
process.env.TESTSPRITE_PORTAL_URL = 'https://portal.example.com';
2425+
});
2426+
afterEach(() => {
2427+
delete process.env.TESTSPRITE_PORTAL_URL;
2428+
});
2429+
2430+
const TEST_ROW = {
2431+
id: 'test_open_me',
2432+
projectId: 'project_alice',
2433+
projectName: 'Alice',
2434+
name: 'Checkout',
2435+
type: 'frontend',
2436+
createdFrom: 'cli',
2437+
status: 'ready',
2438+
createdAt: '2026-06-01T10:00:00.000Z',
2439+
updatedAt: '2026-06-01T10:00:00.000Z',
2440+
};
2441+
2442+
it('prints the dashboard URL and spawns the opener with it', async () => {
2443+
const { credentialsPath } = makeCreds();
2444+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2445+
const out: string[] = [];
2446+
const opened: string[] = [];
2447+
const result = await runOpen(
2448+
{
2449+
profile: 'default',
2450+
output: 'text',
2451+
debug: false,
2452+
testId: 'test_open_me',
2453+
noBrowser: false,
2454+
},
2455+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2456+
url => opened.push(url),
2457+
);
2458+
expect(result.dashboardUrl).toContain('/dashboard/tests/project_alice/test/test_open_me');
2459+
expect(out.join('\n')).toContain(result.dashboardUrl);
2460+
expect(opened).toEqual([result.dashboardUrl]);
2461+
});
2462+
2463+
it('--no-browser prints the URL but never spawns', async () => {
2464+
const { credentialsPath } = makeCreds();
2465+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2466+
const out: string[] = [];
2467+
const opened: string[] = [];
2468+
await runOpen(
2469+
{
2470+
profile: 'default',
2471+
output: 'json',
2472+
debug: false,
2473+
testId: 'test_open_me',
2474+
noBrowser: true,
2475+
},
2476+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2477+
url => opened.push(url),
2478+
);
2479+
expect(opened).toEqual([]);
2480+
expect((JSON.parse(out.join('')) as { dashboardUrl: string }).dashboardUrl).toContain(
2481+
'test_open_me',
2482+
);
2483+
});
2484+
2485+
it('a broken opener downgrades to a stderr hint, never a failure', async () => {
2486+
const { credentialsPath } = makeCreds();
2487+
const fetchImpl = makeFetch(() => ({ body: TEST_ROW }));
2488+
const errs: string[] = [];
2489+
await expect(
2490+
runOpen(
2491+
{
2492+
profile: 'default',
2493+
output: 'text',
2494+
debug: false,
2495+
testId: 'test_open_me',
2496+
noBrowser: false,
2497+
},
2498+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2499+
() => {
2500+
throw new Error('no display');
2501+
},
2502+
),
2503+
).resolves.toBeDefined();
2504+
expect(errs.join('\n')).toContain('could not launch a browser');
2505+
});
2506+
});
2507+
24182508
describe('runSteps', () => {
24192509
it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => {
24202510
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 74 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,
@@ -4115,6 +4116,61 @@ export async function runScaffold(
41154116
return payload;
41164117
}
41174118

4119+
export interface OpenOptions extends CommonOptions {
4120+
testId: string;
4121+
/** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
4122+
noBrowser: boolean;
4123+
}
4124+
4125+
/**
4126+
* `test open <test-id>` (issue #121): jump from the terminal to the test's
4127+
* dashboard page. The CLI already computes this deep-link and prints it as
4128+
* text on other commands; this closes the last inch (the `gh browse` /
4129+
* `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
4130+
* and headless use still compose), then the OS browser is spawned unless
4131+
* --no-browser. An endpoint with no known portal mapping is a hard error
4132+
* rather than a silent no-op.
4133+
*/
4134+
export async function runOpen(
4135+
opts: OpenOptions,
4136+
deps: TestDeps = {},
4137+
opener: (url: string) => void = openInBrowser,
4138+
): Promise<{ dashboardUrl: string }> {
4139+
const out = makeOutput(opts.output, deps);
4140+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4141+
4142+
if (opts.dryRun) {
4143+
emitDryRunBanner(stderrFn);
4144+
const sample = {
4145+
dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`,
4146+
};
4147+
out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl);
4148+
return sample;
4149+
}
4150+
4151+
const client = makeClient(opts, deps);
4152+
// The deep-link needs the projectId; the test record is the source of truth.
4153+
const test = await client.get<CliTest>(`/tests/${encodeURIComponent(opts.testId)}`);
4154+
const dashboardUrl = resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId);
4155+
if (dashboardUrl === undefined) {
4156+
throw new CLIError(
4157+
`no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin`,
4158+
1,
4159+
);
4160+
}
4161+
out.print({ dashboardUrl }, () => dashboardUrl);
4162+
if (!opts.noBrowser) {
4163+
try {
4164+
opener(dashboardUrl);
4165+
} catch {
4166+
// The URL is already on stdout; a missing opener (containers, minimal
4167+
// hosts) downgrades to "open it yourself" instead of a hard failure.
4168+
stderrFn('could not launch a browser; open the URL above manually (or use --no-browser)');
4169+
}
4170+
}
4171+
return { dashboardUrl };
4172+
}
4173+
41184174
export async function runSteps(
41194175
opts: StepsOptions,
41204176
deps: TestDeps = {},
@@ -8150,6 +8206,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
81508206
);
81518207
});
81528208

8209+
test
8210+
.command('open <test-id>')
8211+
.description(
8212+
'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.',
8213+
)
8214+
.option('--no-browser', 'print the URL only (SSH, headless, CI, agents)')
8215+
.addHelpText('after', GLOBAL_OPTS_HINT)
8216+
.action(async (testId: string, cmdOpts: { browser?: boolean }, command: Command) => {
8217+
await runOpen(
8218+
{
8219+
...resolveCommonOptions(command),
8220+
testId,
8221+
noBrowser: cmdOpts.browser === false,
8222+
},
8223+
deps,
8224+
);
8225+
});
8226+
81538227
test
81548228
.command('steps <test-id>')
81558229
.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
@@ -205,6 +205,10 @@ Commands:
205205
definition (frontend plan JSON by
206206
default, or a backend Python skeleton).
207207
Pure-local: no network, no credentials.
208+
open [options] <test-id> Open the test in the TestSprite
209+
dashboard: prints the deep-link URL,
210+
then spawns your default browser unless
211+
--no-browser.
208212
steps [options] <test-id> List the steps for a test (server
209213
returns the cumulative log across every
210214
run; use --run-id to scope to one run)

0 commit comments

Comments
 (0)