Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/commands/test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
runGet,
runLint,
runList,
runOpen,
runPlanPut,
runResult,
runScaffold,
Expand Down Expand Up @@ -131,6 +132,7 @@ describe('createTestCommand — surface', () => {
'get',
'lint',
'list',
'open',
'plan',
'rerun',
'result',
Expand Down Expand Up @@ -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();
Expand Down
80 changes: 80 additions & 0 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4115,6 +4116,67 @@ 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 <test-id>` (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);
// 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:
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;
}
Comment on lines +4142 to +4155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Dry-run branch bypasses the canned-sample contract and duplicates URL-building logic.

Per path instructions, a new endpoint needs "a matching canned sample in src/lib/dry-run/samples.ts (and its test) so --dry-run stays offline." Instead, this hardcodes a brand-new dashboardUrl template (https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/...) directly in runOpen, entirely bypassing resolvePortalUrl and the client. This duplicates the URL-format logic that already lives in resolvePortalUrl (src/lib/facade.ts), so the two can silently drift (e.g., if resolvePortalUrl's path shape changes, this literal won't follow). It also doesn't encodeURIComponent testId the way the real path does, so dry-run output can differ in shape from production output for the same input.

The project's own stated design principle is that --dry-run should "exercise the full code path offline with canned data" — i.e., dry-run should still flow through client.get (backed by a canned CliTest fixture from samples.ts) and resolvePortalUrl, not shortcut around them with a separately-maintained literal.

There's also no dry-run test case in src/commands/test.test.ts's describe('runOpen', ...) block, compounding the gap.

♻️ Suggested direction
   if (opts.dryRun) {
     emitDryRunBanner(stderrFn);
-    const sample = {
-      dashboardUrl: `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${opts.testId}`,
-    };
+    const test = DRY_RUN_SAMPLES.test; // canned CliTest fixture from src/lib/dry-run/samples.ts
+    const dashboardUrl =
+      resolvePortalUrl(resolveApiUrl(opts, deps), test.projectId, opts.testId) ??
+      `https://www.testsprite.com/dashboard/tests/${test.projectId}/test/${encodeURIComponent(opts.testId)}`;
+    const sample = { dashboardUrl };
     out.print(sample, data => (data as { dashboardUrl: string }).dashboardUrl);
     return sample;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/test.ts` around lines 3657 - 3664, The dry-run branch in runOpen
is bypassing the canned-sample flow and duplicating portal URL construction.
Update the opts.dryRun path to use the offline client.get-backed fixture from
src/lib/dry-run/samples.ts and let resolvePortalUrl in src/lib/facade.ts build
the dashboard URL, rather than hardcoding a template and returning a custom
sample. Also add a dry-run case to the runOpen tests in
src/commands/test.test.ts to verify the canned sample contract and keep the
offline path aligned with production behavior.

Source: Path instructions


const client = makeClient(opts, deps);
// The deep-link needs the projectId; the test record is the source of truth.
const test = await client.get<CliTest>(`/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 = {},
Expand Down Expand Up @@ -8150,6 +8212,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
);
});

test
.command('open <test-id>')
.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 <test-id>')
.description(
Expand Down
100 changes: 100 additions & 0 deletions src/lib/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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';
import { ApiError } from './errors.js';

describe('openInBrowser', () => {
const url = 'https://portal.example.com/tests/t_123';

it('uses `open <url>` 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 with exit 5 before spawning', () => {
const exec = vi.fn();
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();
});

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, 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<string, (err: Error) => 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();
}
});
});
});
63 changes: 63 additions & 0 deletions src/lib/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* 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 <url>
* win32 rundll32 url.dll,FileProtocolHandler <url> (avoids `cmd /c start`,
* whose re-parsing would mangle `&` and other metachars in the URL)
* other xdg-open <url>
*
* 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';
import { localValidationError } from './errors.js';

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:') {
// 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const exec =
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();
});

if (platform === 'darwin') {
exec('open', [url]);
return;
}
if (platform === 'win32') {
exec('rundll32', ['url.dll,FileProtocolHandler', url]);
return;
}
exec('xdg-open', [url]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 4 additions & 0 deletions test/__snapshots__/help.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ Commands:
definition (frontend plan JSON by
default, or a backend Python skeleton).
Pure-local: no network, no credentials.
open [options] <test-id> Open the test in the TestSprite
dashboard: prints the deep-link URL,
then spawns your default browser unless
--no-browser.
steps [options] <test-id> List the steps for a test (server
returns the cumulative log across every
run; use --run-id to scope to one run)
Expand Down
Loading