@@ -3,6 +3,7 @@ import { rename, stat, unlink } from 'node:fs/promises';
33import { basename , dirname , extname , isAbsolute , join , resolve } from 'node:path' ;
44import { randomUUID } from 'node:crypto' ;
55import { Command } from 'commander' ;
6+ import { openInBrowser } from '../lib/browser.js' ;
67import {
78 emitDryRunBanner ,
89 makeHttpClient ,
@@ -4000,6 +4001,61 @@ export async function runLint(opts: LintOptions, deps: TestDeps = {}): Promise<C
40004001 return report ;
40014002}
40024003
4004+ export interface OpenOptions extends CommonOptions {
4005+ testId : string ;
4006+ /** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
4007+ noBrowser : boolean ;
4008+ }
4009+
4010+ /**
4011+ * `test open <test-id>` (issue #121): jump from the terminal to the test's
4012+ * dashboard page. The CLI already computes this deep-link and prints it as
4013+ * text on other commands; this closes the last inch (the `gh browse` /
4014+ * `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
4015+ * and headless use still compose), then the OS browser is spawned unless
4016+ * --no-browser. An endpoint with no known portal mapping is a hard error
4017+ * rather than a silent no-op.
4018+ */
4019+ export async function runOpen (
4020+ opts : OpenOptions ,
4021+ deps : TestDeps = { } ,
4022+ opener : ( url : string ) => void = openInBrowser ,
4023+ ) : Promise < { dashboardUrl : string } > {
4024+ const out = makeOutput ( opts . output , deps ) ;
4025+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
4026+
4027+ if ( opts . dryRun ) {
4028+ emitDryRunBanner ( stderrFn ) ;
4029+ const sample = {
4030+ dashboardUrl : `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${ opts . testId } ` ,
4031+ } ;
4032+ out . print ( sample , data => ( data as { dashboardUrl : string } ) . dashboardUrl ) ;
4033+ return sample ;
4034+ }
4035+
4036+ const client = makeClient ( opts , deps ) ;
4037+ // The deep-link needs the projectId; the test record is the source of truth.
4038+ const test = await client . get < CliTest > ( `/tests/${ encodeURIComponent ( opts . testId ) } ` ) ;
4039+ const dashboardUrl = resolvePortalUrl ( resolveApiUrl ( opts , deps ) , test . projectId , opts . testId ) ;
4040+ if ( dashboardUrl === undefined ) {
4041+ throw new CLIError (
4042+ `no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin` ,
4043+ 1 ,
4044+ ) ;
4045+ }
4046+ out . print ( { dashboardUrl } , ( ) => dashboardUrl ) ;
4047+ if ( ! opts . noBrowser ) {
4048+ try {
4049+ opener ( dashboardUrl ) ;
4050+ } catch {
4051+ // The URL is already on stdout; a missing opener (containers, minimal
4052+ // hosts) downgrades to "open it yourself" instead of a hard failure.
4053+ stderrFn ( 'could not launch a browser; open the URL above manually (or use --no-browser)' ) ;
4054+ }
4055+ }
4056+ return { dashboardUrl } ;
4057+ }
4058+
40034059export async function runSteps (
40044060 opts : StepsOptions ,
40054061 deps : TestDeps = { } ,
@@ -7770,6 +7826,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
77707826 ) ;
77717827 } ) ;
77727828
7829+ test
7830+ . command ( 'open <test-id>' )
7831+ . description (
7832+ 'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.' ,
7833+ )
7834+ . option ( '--no-browser' , 'print the URL only (SSH, headless, CI, agents)' )
7835+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7836+ . action ( async ( testId : string , cmdOpts : { browser ?: boolean } , command : Command ) => {
7837+ await runOpen (
7838+ {
7839+ ...resolveCommonOptions ( command ) ,
7840+ testId,
7841+ noBrowser : cmdOpts . browser === false ,
7842+ } ,
7843+ deps ,
7844+ ) ;
7845+ } ) ;
7846+
77737847 test
77747848 . command ( 'steps <test-id>' )
77757849 . description (
0 commit comments