11import process from 'node:process' ;
2+ import childProcess from 'node:child_process' ;
3+ import { EventEmitter } from 'node:events' ;
24import test from 'ava' ;
5+ import defaultBrowser from 'default-browser' ;
36import open , { openApp , apps } from './index.js' ;
47
58// Tests only checks that opening doesn't return an error
69// it has no way make sure that it actually opened anything.
710
811// These have to be manually verified.
912
13+ // Helper to check if Safari is the default browser
14+ const isSafariDefault = async ( ) => {
15+ try {
16+ const browser = await defaultBrowser ( ) ;
17+ return browser . id === 'com.apple.Safari' ;
18+ } catch {
19+ // If default-browser fails (e.g., on systems without a default browser)
20+ return false ;
21+ }
22+ } ;
23+
1024test ( 'open file in default app' , async t => {
1125 await t . notThrowsAsync ( open ( 'index.js' ) ) ;
1226} ) ;
1327
14- test ( 'wait for the app to close if wait: true' , async t => {
15- await t . notThrowsAsync ( open ( 'https://sindresorhus.com' , { wait : true } ) ) ;
16- } ) ;
17-
1828test ( 'encode URL if url: true' , async t => {
1929 await t . notThrowsAsync ( open ( 'https://sindresorhus.com' , { url : true } ) ) ;
2030} ) ;
@@ -30,7 +40,7 @@ test('open URL in specified app', async t => {
3040test ( 'open URL in specified app with arguments' , async t => {
3141 await t . notThrowsAsync ( async ( ) => {
3242 const process_ = await open ( 'https://sindresorhus.com' , { app : { name : apps . chrome , arguments : [ '--incognito' ] } } ) ;
33- t . deepEqual ( process_ . spawnargs , [ 'open' , '-a' , apps . chrome , 'https://sindresorhus.com' , ' --args', '--incognito' ] ) ;
43+ t . deepEqual ( process_ . spawnargs , [ 'open' , '-a' , apps . chrome , '--args' , '--incognito' , 'https://sindresorhus.com '] ) ;
3444 } ) ;
3545} ) ;
3646
@@ -96,15 +106,29 @@ test('open URL with default browser argument', async t => {
96106} ) ;
97107
98108test ( 'open URL with default browser in incognito mode' , async t => {
99- await t . notThrowsAsync ( open ( 'https://sindresorhus.com' , { app : { name : apps . browserPrivate } } ) ) ;
109+ if ( await isSafariDefault ( ) ) {
110+ await t . throwsAsync (
111+ open ( 'https://sindresorhus.com' , { app : { name : apps . browserPrivate } } ) ,
112+ { message : / S a f a r i d o e s n ' t s u p p o r t o p e n i n g i n p r i v a t e m o d e v i a c o m m a n d l i n e / } ,
113+ ) ;
114+ } else {
115+ await t . notThrowsAsync ( open ( 'https://sindresorhus.com' , { app : { name : apps . browserPrivate } } ) ) ;
116+ }
100117} ) ;
101118
102119test ( 'open default browser' , async t => {
103120 await t . notThrowsAsync ( openApp ( apps . browser , { newInstance : true } ) ) ;
104121} ) ;
105122
106123test ( 'open default browser in incognito mode' , async t => {
107- await t . notThrowsAsync ( openApp ( apps . browserPrivate , { newInstance : true } ) ) ;
124+ if ( await isSafariDefault ( ) ) {
125+ await t . throwsAsync (
126+ openApp ( apps . browserPrivate , { newInstance : true } ) ,
127+ { message : / S a f a r i d o e s n ' t s u p p o r t o p e n i n g i n p r i v a t e m o d e v i a c o m m a n d l i n e / } ,
128+ ) ;
129+ } else {
130+ await t . notThrowsAsync ( openApp ( apps . browserPrivate , { newInstance : true } ) ) ;
131+ }
108132} ) ;
109133
110134test ( 'subprocess is spawned before promise resolves' , async t => {
@@ -115,6 +139,70 @@ test('subprocess is spawned before promise resolves', async t => {
115139 t . true ( childProcess . pid !== undefined && childProcess . pid !== null ) ;
116140} ) ;
117141
142+ test . serial ( 'app launches resolve before close without fallback' , async t => {
143+ const originalSpawn = childProcess . spawn ;
144+ t . teardown ( ( ) => {
145+ childProcess . spawn = originalSpawn ;
146+ } ) ;
147+
148+ let closeEmitted = false ;
149+
150+ childProcess . spawn = ( ) => {
151+ // eslint-disable-next-line unicorn/prefer-event-target
152+ const fakeChild = new EventEmitter ( ) ;
153+ fakeChild . unref = ( ) => { } ;
154+
155+ setImmediate ( ( ) => {
156+ fakeChild . emit ( 'spawn' ) ;
157+ setTimeout ( ( ) => {
158+ closeEmitted = true ;
159+ fakeChild . emit ( 'close' , 0 ) ;
160+ } , 50 ) ;
161+ } ) ;
162+
163+ return fakeChild ;
164+ } ;
165+
166+ const subprocess = await open ( 'index.js' , { app : { name : 'stub-app' } } ) ;
167+ t . false ( closeEmitted ) ;
168+ t . truthy ( subprocess ) ;
169+ } ) ;
170+
171+ test ( 'fallback to next app when first app does not exist' , async t => {
172+ // Try nonexistent apps first, then a real app
173+ // Note: This test may fail if all apps return non-zero exit codes (system issue)
174+ try {
175+ await open ( 'https://sindresorhus.com' , {
176+ app : {
177+ name : [ 'definitely-not-a-real-app-12345' , 'another-fake-app-67890' , apps . chrome ] ,
178+ } ,
179+ } ) ;
180+ t . pass ( 'Fallback succeeded' ) ;
181+ } catch ( error ) {
182+ if ( error instanceof AggregateError && error . errors . every ( error => error . message . includes ( 'Exited with code' ) ) ) {
183+ // All apps failed with exit codes - this might be a system issue
184+ // where even valid apps return non-zero exit codes
185+ t . pass ( 'All apps returned non-zero exit codes (possible system issue)' ) ;
186+ } else {
187+ throw error ;
188+ }
189+ }
190+ } ) ;
191+
192+ test ( 'throws AggregateError when all apps in array fail' , async t => {
193+ const error = await t . throwsAsync (
194+ open ( 'https://sindresorhus.com' , {
195+ app : {
196+ name : [ 'fake-app-1' , 'fake-app-2' , 'fake-app-3' ] ,
197+ } ,
198+ } ) ,
199+ { instanceOf : AggregateError } ,
200+ ) ;
201+
202+ t . is ( error . errors . length , 3 ) ;
203+ t . true ( error . message . includes ( 'Failed to open in all supported apps' ) ) ;
204+ } ) ;
205+
118206if ( process . platform === 'linux' ) {
119207 test ( 'spawn errors reject the promise instead of crashing' , async t => {
120208 const error = await t . throwsAsync ( openApp ( 'definitely-not-a-real-command-12345' ) ) ;
0 commit comments