@@ -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 ,
@@ -3854,6 +3855,61 @@ function renderRunDiffText(diff: CliRunDiff): string {
38543855 return lines . join ( '\n' ) ;
38553856}
38563857
3858+ export interface OpenOptions extends CommonOptions {
3859+ testId : string ;
3860+ /** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
3861+ noBrowser : boolean ;
3862+ }
3863+
3864+ /**
3865+ * `test open <test-id>` (issue #121): jump from the terminal to the test's
3866+ * dashboard page. The CLI already computes this deep-link and prints it as
3867+ * text on other commands; this closes the last inch (the `gh browse` /
3868+ * `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
3869+ * and headless use still compose), then the OS browser is spawned unless
3870+ * --no-browser. An endpoint with no known portal mapping is a hard error
3871+ * rather than a silent no-op.
3872+ */
3873+ export async function runOpen (
3874+ opts : OpenOptions ,
3875+ deps : TestDeps = { } ,
3876+ opener : ( url : string ) => void = openInBrowser ,
3877+ ) : Promise < { dashboardUrl : string } > {
3878+ const out = makeOutput ( opts . output , deps ) ;
3879+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
3880+
3881+ if ( opts . dryRun ) {
3882+ emitDryRunBanner ( stderrFn ) ;
3883+ const sample = {
3884+ dashboardUrl : `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${ opts . testId } ` ,
3885+ } ;
3886+ out . print ( sample , data => ( data as { dashboardUrl : string } ) . dashboardUrl ) ;
3887+ return sample ;
3888+ }
3889+
3890+ const client = makeClient ( opts , deps ) ;
3891+ // The deep-link needs the projectId; the test record is the source of truth.
3892+ const test = await client . get < CliTest > ( `/tests/${ encodeURIComponent ( opts . testId ) } ` ) ;
3893+ const dashboardUrl = resolvePortalUrl ( resolveApiUrl ( opts , deps ) , test . projectId , opts . testId ) ;
3894+ if ( dashboardUrl === undefined ) {
3895+ throw new CLIError (
3896+ `no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin` ,
3897+ 1 ,
3898+ ) ;
3899+ }
3900+ out . print ( { dashboardUrl } , ( ) => dashboardUrl ) ;
3901+ if ( ! opts . noBrowser ) {
3902+ try {
3903+ opener ( dashboardUrl ) ;
3904+ } catch {
3905+ // The URL is already on stdout; a missing opener (containers, minimal
3906+ // hosts) downgrades to "open it yourself" instead of a hard failure.
3907+ stderrFn ( 'could not launch a browser; open the URL above manually (or use --no-browser)' ) ;
3908+ }
3909+ }
3910+ return { dashboardUrl } ;
3911+ }
3912+
38573913export async function runSteps (
38583914 opts : StepsOptions ,
38593915 deps : TestDeps = { } ,
@@ -7624,6 +7680,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
76247680 ) ;
76257681 } ) ;
76267682
7683+ test
7684+ . command ( 'open <test-id>' )
7685+ . description (
7686+ 'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.' ,
7687+ )
7688+ . option ( '--no-browser' , 'print the URL only (SSH, headless, CI, agents)' )
7689+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7690+ . action ( async ( testId : string , cmdOpts : { browser ?: boolean } , command : Command ) => {
7691+ await runOpen (
7692+ {
7693+ ...resolveCommonOptions ( command ) ,
7694+ testId,
7695+ noBrowser : cmdOpts . browser === false ,
7696+ } ,
7697+ deps ,
7698+ ) ;
7699+ } ) ;
7700+
76277701 test
76287702 . command ( 'steps <test-id>' )
76297703 . description (
0 commit comments