@@ -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 ,
@@ -3630,6 +3631,61 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36303631 } ;
36313632}
36323633
3634+ export interface OpenOptions extends CommonOptions {
3635+ testId : string ;
3636+ /** Print the URL only; never spawn a browser (SSH/headless/CI/agents). */
3637+ noBrowser : boolean ;
3638+ }
3639+
3640+ /**
3641+ * `test open <test-id>` (issue #121): jump from the terminal to the test's
3642+ * dashboard page. The CLI already computes this deep-link and prints it as
3643+ * text on other commands; this closes the last inch (the `gh browse` /
3644+ * `cypress open` hop). The URL is ALWAYS printed to stdout (so `--no-browser`
3645+ * and headless use still compose), then the OS browser is spawned unless
3646+ * --no-browser. An endpoint with no known portal mapping is a hard error
3647+ * rather than a silent no-op.
3648+ */
3649+ export async function runOpen (
3650+ opts : OpenOptions ,
3651+ deps : TestDeps = { } ,
3652+ opener : ( url : string ) => void = openInBrowser ,
3653+ ) : Promise < { dashboardUrl : string } > {
3654+ const out = makeOutput ( opts . output , deps ) ;
3655+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
3656+
3657+ if ( opts . dryRun ) {
3658+ emitDryRunBanner ( stderrFn ) ;
3659+ const sample = {
3660+ dashboardUrl : `https://www.testsprite.com/dashboard/tests/p_dryrun_2026/test/${ opts . testId } ` ,
3661+ } ;
3662+ out . print ( sample , data => ( data as { dashboardUrl : string } ) . dashboardUrl ) ;
3663+ return sample ;
3664+ }
3665+
3666+ const client = makeClient ( opts , deps ) ;
3667+ // The deep-link needs the projectId; the test record is the source of truth.
3668+ const test = await client . get < CliTest > ( `/tests/${ encodeURIComponent ( opts . testId ) } ` ) ;
3669+ const dashboardUrl = resolvePortalUrl ( resolveApiUrl ( opts , deps ) , test . projectId , opts . testId ) ;
3670+ if ( dashboardUrl === undefined ) {
3671+ throw new CLIError (
3672+ `no dashboard mapping for this API endpoint; set TESTSPRITE_PORTAL_URL to your Portal origin` ,
3673+ 1 ,
3674+ ) ;
3675+ }
3676+ out . print ( { dashboardUrl } , ( ) => dashboardUrl ) ;
3677+ if ( ! opts . noBrowser ) {
3678+ try {
3679+ opener ( dashboardUrl ) ;
3680+ } catch {
3681+ // The URL is already on stdout; a missing opener (containers, minimal
3682+ // hosts) downgrades to "open it yourself" instead of a hard failure.
3683+ stderrFn ( 'could not launch a browser; open the URL above manually (or use --no-browser)' ) ;
3684+ }
3685+ }
3686+ return { dashboardUrl } ;
3687+ }
3688+
36333689export async function runSteps (
36343690 opts : StepsOptions ,
36353691 deps : TestDeps = { } ,
@@ -7207,6 +7263,24 @@ export function createTestCommand(deps: TestDeps = {}): Command {
72077263 ) ;
72087264 } ) ;
72097265
7266+ test
7267+ . command ( 'open <test-id>' )
7268+ . description (
7269+ 'Open the test in the TestSprite dashboard: prints the deep-link URL, then spawns your default browser unless --no-browser.' ,
7270+ )
7271+ . option ( '--no-browser' , 'print the URL only (SSH, headless, CI, agents)' )
7272+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7273+ . action ( async ( testId : string , cmdOpts : { browser ?: boolean } , command : Command ) => {
7274+ await runOpen (
7275+ {
7276+ ...resolveCommonOptions ( command ) ,
7277+ testId,
7278+ noBrowser : cmdOpts . browser === false ,
7279+ } ,
7280+ deps ,
7281+ ) ;
7282+ } ) ;
7283+
72107284 test
72117285 . command ( 'steps <test-id>' )
72127286 . description (
0 commit comments