From 126661821b3c77d3bc1060862402625e0a3218db Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:39:36 -0400 Subject: [PATCH 1/3] feat(test): add "test open " to jump from the terminal to the dashboard --- src/commands/test.test.ts | 90 +++++++++++++++++++ src/commands/test.ts | 74 +++++++++++++++ src/lib/browser.test.ts | 70 +++++++++++++++ src/lib/browser.ts | 46 ++++++++++ test/__snapshots__/help.snapshot.test.ts.snap | 4 + 5 files changed, 284 insertions(+) create mode 100644 src/lib/browser.test.ts create mode 100644 src/lib/browser.ts diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 6ae831c..468aaac 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -33,6 +33,7 @@ import { runGet, runLint, runList, + runOpen, runPlanPut, runResult, runScaffold, @@ -131,6 +132,7 @@ describe('createTestCommand — surface', () => { 'get', 'lint', 'list', + 'open', 'plan', 'rerun', 'result', @@ -2415,6 +2417,94 @@ describe('runScaffold', () => { }); }); +describe('runOpen', () => { + // The mock endpoint host has no portal mapping; the operator override is the + // supported escape hatch and gives the tests a deterministic base. + beforeEach(() => { + process.env.TESTSPRITE_PORTAL_URL = 'https://portal.example.com'; + }); + afterEach(() => { + delete process.env.TESTSPRITE_PORTAL_URL; + }); + + const TEST_ROW = { + id: 'test_open_me', + projectId: 'project_alice', + projectName: 'Alice', + name: 'Checkout', + type: 'frontend', + createdFrom: 'cli', + status: 'ready', + createdAt: '2026-06-01T10:00:00.000Z', + updatedAt: '2026-06-01T10:00:00.000Z', + }; + + it('prints the dashboard URL and spawns the opener with it', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: TEST_ROW })); + const out: string[] = []; + const opened: string[] = []; + const result = await runOpen( + { + profile: 'default', + output: 'text', + debug: false, + testId: 'test_open_me', + noBrowser: false, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + url => opened.push(url), + ); + expect(result.dashboardUrl).toContain('/dashboard/tests/project_alice/test/test_open_me'); + expect(out.join('\n')).toContain(result.dashboardUrl); + expect(opened).toEqual([result.dashboardUrl]); + }); + + it('--no-browser prints the URL but never spawns', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: TEST_ROW })); + const out: string[] = []; + const opened: string[] = []; + await runOpen( + { + profile: 'default', + output: 'json', + debug: false, + testId: 'test_open_me', + noBrowser: true, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + url => opened.push(url), + ); + expect(opened).toEqual([]); + expect((JSON.parse(out.join('')) as { dashboardUrl: string }).dashboardUrl).toContain( + 'test_open_me', + ); + }); + + it('a broken opener downgrades to a stderr hint, never a failure', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: TEST_ROW })); + const errs: string[] = []; + await expect( + runOpen( + { + profile: 'default', + output: 'text', + debug: false, + testId: 'test_open_me', + noBrowser: false, + }, + { credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) }, + () => { + throw new Error('no display'); + }, + ), + ).resolves.toBeDefined(); + expect(errs.join('\n')).toContain('could not launch a browser'); + }); +}); + describe('runSteps', () => { it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => { const { credentialsPath } = makeCreds(); diff --git a/src/commands/test.ts b/src/commands/test.ts index 1c6edd8..04dcfe3 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -10,6 +10,7 @@ import { rename, stat, unlink } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; import { Command } from 'commander'; +import { openInBrowser } from '../lib/browser.js'; import { emitDryRunBanner, makeHttpClient, @@ -4115,6 +4116,61 @@ export async function runScaffold( return payload; } +export interface OpenOptions extends CommonOptions { + testId: string; + /** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */ + noBrowser: boolean; +} + +/** + * `test open ` (issue #121): jump from the terminal to the test's + * dashboard page. The CLI already computes this deep-link and prints it as + * text on other commands; this closes the last inch (the `gh browse` / + * `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser` + * and headless use still compose), then the OS browser is spawned unless + * --no-browser. An endpoint with no known portal mapping is a hard error + * rather than a silent no-op. + */ +export async function runOpen( + opts: OpenOptions, + deps: TestDeps = {}, + opener: (url: string) => void = openInBrowser, +): Promise<{ dashboardUrl: string }> { + const out = makeOutput(opts.output, deps); + const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); + + if (opts.dryRun) { + emitDryRunBanner(stderrFn); + const sample = { + dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`, + }; + out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl); + return sample; + } + + const client = makeClient(opts, deps); + // The deep-link needs the projectId; the test record is the source of truth. + const test = await client.get(`/tests/${encodeURIComponent(opts.testId)}`); + const dashboardUrl = resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId); + if (dashboardUrl === undefined) { + throw new CLIError( + `no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin`, + 1, + ); + } + out.print({ dashboardUrl }, () => dashboardUrl); + if (!opts.noBrowser) { + try { + opener(dashboardUrl); + } catch { + // The URL is already on stdout; a missing opener (containers, minimal + // hosts) downgrades to "open it yourself" instead of a hard failure. + stderrFn('could not launch a browser; open the URL above manually (or use --no-browser)'); + } + } + return { dashboardUrl }; +} + export async function runSteps( opts: StepsOptions, deps: TestDeps = {}, @@ -8150,6 +8206,24 @@ export function createTestCommand(deps: TestDeps = {}): Command { ); }); + test + .command('open ') + .description( + 'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.', + ) + .option('--no-browser', 'print the URL only (SSH, headless, CI, agents)') + .addHelpText('after', GLOBAL_OPTS_HINT) + .action(async (testId: string, cmdOpts: { browser?: boolean }, command: Command) => { + await runOpen( + { + ...resolveCommonOptions(command), + testId, + noBrowser: cmdOpts.browser === false, + }, + deps, + ); + }); + test .command('steps ') .description( diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts new file mode 100644 index 0000000..9f77f80 --- /dev/null +++ b/src/lib/browser.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the real spawner so the default `exec` path (detached spawn + unref) +// is exercised without launching a real process. +const spawnMock = vi.fn(); +vi.mock('node:child_process', () => ({ + spawn: (command: string, args: readonly string[], opts: unknown) => + spawnMock(command, args, opts), +})); + +import { openInBrowser } from './browser.js'; + +describe('openInBrowser', () => { + const url = 'https://portal.example.com/tests/t_123'; + + it('uses `open ` on darwin', () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + openInBrowser(url, { + platform: 'darwin', + exec: (command, args) => calls.push({ command, args }), + }); + expect(calls).toEqual([{ command: 'open', args: [url] }]); + }); + + it('uses rundll32 FileProtocolHandler on win32', () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + openInBrowser(url, { + platform: 'win32', + exec: (command, args) => calls.push({ command, args }), + }); + expect(calls).toEqual([{ command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }]); + }); + + it('uses xdg-open on other platforms', () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + openInBrowser(url, { + platform: 'linux', + exec: (command, args) => calls.push({ command, args }), + }); + expect(calls).toEqual([{ command: 'xdg-open', args: [url] }]); + }); + + it('refuses a non-http(s) URL before spawning', () => { + const exec = vi.fn(); + expect(() => openInBrowser('file:///etc/passwd', { platform: 'linux', exec })).toThrow( + /non-http\(s\)/, + ); + expect(exec).not.toHaveBeenCalled(); + }); + + it('throws on a malformed URL', () => { + const exec = vi.fn(); + expect(() => openInBrowser('not a url', { exec })).toThrow(); + expect(exec).not.toHaveBeenCalled(); + }); + + describe('default spawner', () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + it('spawns detached, ignores stdio, and unrefs the child', () => { + const unref = vi.fn(); + spawnMock.mockReturnValue({ unref }); + openInBrowser(url, { platform: 'darwin' }); + expect(spawnMock).toHaveBeenCalledWith('open', [url], { detached: true, stdio: 'ignore' }); + expect(unref).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/lib/browser.ts b/src/lib/browser.ts new file mode 100644 index 0000000..dae3d4b --- /dev/null +++ b/src/lib/browser.ts @@ -0,0 +1,46 @@ +/** + * Cross-platform "open this URL in the default browser" helper for + * `test open` (issue #121). Spawns the platform opener with an argv array + * (never a shell string) so a URL can never be shell-injected, and refuses + * anything that is not http(s) before any process is spawned. + * + * Platform openers: + * darwin open + * win32 rundll32 url.dll,FileProtocolHandler (avoids `cmd /c start`, + * whose re-parsing would mangle `&` and other metachars in the URL) + * other xdg-open + * + * The child is detached and unref'd so the CLI exits immediately; failures + * are the caller's to surface (it already printed the URL as the fallback). + */ +import { spawn } from 'node:child_process'; + +export interface OpenInBrowserDeps { + platform?: NodeJS.Platform; + /** Process spawner taking an argv array. Defaults to a detached spawn. */ + exec?: (command: string, args: readonly string[]) => void; +} + +export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): void { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`refusing to open a non-http(s) URL (${parsed.protocol})`); + } + const platform = deps.platform ?? process.platform; + const exec = + deps.exec ?? + ((command: string, args: readonly string[]) => { + const child = spawn(command, [...args], { detached: true, stdio: 'ignore' }); + child.unref(); + }); + + if (platform === 'darwin') { + exec('open', [url]); + return; + } + if (platform === 'win32') { + exec('rundll32', ['url.dll,FileProtocolHandler', url]); + return; + } + exec('xdg-open', [url]); +} diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index fcc3d85..cc5da7c 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -205,6 +205,10 @@ Commands: definition (frontend plan JSON by default, or a backend Python skeleton). Pure-local: no network, no credentials. + open [options] Open the test in the TestSprite + dashboard: prints the deep-link URL, + then spawns your default browser unless + --no-browser. steps [options] List the steps for a test (server returns the cumulative log across every run; use --run-id to scope to one run) From 782b3af9540abbb2fe69a12009a34258bafcfad8 Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:02:17 -0400 Subject: [PATCH 2/3] fix(open): derive the dry-run URL via resolvePortalUrl and handle async spawn ENOENT --- src/commands/test.ts | 8 +++++++- src/lib/browser.test.ts | 26 +++++++++++++++++++++++++- src/lib/browser.ts | 9 +++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/commands/test.ts b/src/commands/test.ts index 04dcfe3..0df8f77 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -4141,8 +4141,14 @@ export async function runOpen( if (opts.dryRun) { emitDryRunBanner(stderrFn); + // Derive the sample through the SAME resolver as the live path (against + // the canonical prod endpoint and the dry-run project id) so the two can + // never drift; the ?? arm is unreachable for the prod mapping but keeps + // the type total. const sample = { - dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`, + dashboardUrl: + resolvePortalUrl('https://api.testsprite.com', 'p_dryrun_2026', opts.testId) ?? + `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${encodeURIComponent(opts.testId)}`, }; out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl); return sample; diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index 9f77f80..41d50e3 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -61,10 +61,34 @@ describe('openInBrowser', () => { it('spawns detached, ignores stdio, and unrefs the child', () => { const unref = vi.fn(); - spawnMock.mockReturnValue({ unref }); + spawnMock.mockReturnValue({ unref, on: vi.fn() }); openInBrowser(url, { platform: 'darwin' }); expect(spawnMock).toHaveBeenCalledWith('open', [url], { detached: true, stdio: 'ignore' }); expect(unref).toHaveBeenCalledTimes(1); }); + + it("handles the child's async 'error' (missing binary) with a stderr hint, not a crash", () => { + // spawn() reports ENOENT asynchronously on the child; an unhandled + // 'error' event would crash the CLI. The default spawner must register + // a listener that degrades to the manual-open hint. + const listeners = new Map void>(); + spawnMock.mockReturnValue({ + unref: vi.fn(), + on: (event: string, listener: (err: Error) => void) => { + listeners.set(event, listener); + }, + }); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + openInBrowser(url, { platform: 'linux' }); + const onError = listeners.get('error'); + expect(onError).toBeDefined(); + // Firing the listener must not throw and must print the hint. + expect(() => onError!(new Error('spawn xdg-open ENOENT'))).not.toThrow(); + expect(String(stderrSpy.mock.calls.at(-1)?.[0])).toContain('could not launch a browser'); + } finally { + stderrSpy.mockRestore(); + } + }); }); }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index dae3d4b..c375d64 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -31,6 +31,15 @@ export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): void { deps.exec ?? ((command: string, args: readonly string[]) => { const child = spawn(command, [...args], { detached: true, stdio: 'ignore' }); + // spawn() reports a missing binary (ENOENT) ASYNCHRONOUSLY on the child, + // so the caller's try/catch cannot see it — and an unhandled 'error' + // event would crash the whole CLI. The URL is already on stdout, so + // degrade to the same manual-open hint the sync failure path prints. + child.on('error', () => { + process.stderr.write( + 'could not launch a browser; open the URL above manually (or use --no-browser)\n', + ); + }); child.unref(); }); From bbc079f0ce2e00a56c2160d9fcfe9efef4413839 Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:56:24 -0400 Subject: [PATCH 3/3] 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. --- src/lib/browser.test.ts | 14 ++++++++++---- src/lib/browser.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index 41d50e3..3d6717b 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -9,6 +9,7 @@ vi.mock('node:child_process', () => ({ })); import { openInBrowser } from './browser.js'; +import { ApiError } from './errors.js'; describe('openInBrowser', () => { const url = 'https://portal.example.com/tests/t_123'; @@ -40,11 +41,16 @@ describe('openInBrowser', () => { expect(calls).toEqual([{ command: 'xdg-open', args: [url] }]); }); - it('refuses a non-http(s) URL before spawning', () => { + it('refuses a non-http(s) URL with exit 5 before spawning', () => { const exec = vi.fn(); - expect(() => openInBrowser('file:///etc/passwd', { platform: 'linux', exec })).toThrow( - /non-http\(s\)/, - ); + let error: unknown; + try { + openInBrowser('file:///etc/passwd', { platform: 'linux', exec }); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).exitCode).toBe(5); expect(exec).not.toHaveBeenCalled(); }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index c375d64..ccd2b8a 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -14,6 +14,7 @@ * are the caller's to surface (it already printed the URL as the fallback). */ import { spawn } from 'node:child_process'; +import { localValidationError } from './errors.js'; export interface OpenInBrowserDeps { platform?: NodeJS.Platform; @@ -24,7 +25,14 @@ export interface OpenInBrowserDeps { export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): void { const parsed = new URL(url); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - throw new Error(`refusing to open a non-http(s) URL (${parsed.protocol})`); + // User-input error, not an internal failure: classify as VALIDATION_ERROR + // so it maps to exit 5 (like every other bad-argument path), not exit 1. + throw localValidationError( + 'url', + `must be an http(s) URL (got ${parsed.protocol})`, + undefined, + 'field', + ); } const platform = deps.platform ?? process.platform; const exec =