@@ -10,6 +10,7 @@ import { rename, stat, unlink } from 'node:fs/promises';
1010import { basename , dirname , extname , isAbsolute , join , resolve } from 'node:path' ;
1111import { randomUUID } from 'node:crypto' ;
1212import { Command } from 'commander' ;
13+ import { openInBrowser } from '../lib/browser.js' ;
1314import {
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+
41184174export 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 (
0 commit comments