11import { remote } from 'webdriverio' ;
22import { execFile } from 'node:child_process' ;
3+ import net from 'node:net' ;
34
45type RemoteOptions = Parameters < typeof remote > [ 0 ] ;
56
67let current : WebdriverIO . Browser | undefined ;
78
9+ // The in-app (tauri-plugin-webdriver) server the app binds once tauri-webdriver
10+ // spawns it. Only one app instance can hold it at a time, so a stale instance
11+ // here is what stalls the next spec's newSession.
12+ const IN_APP_WEBDRIVER_PORT = 4445 ;
13+
814/**
915 * Windows-only safety net against native-dialog session poisoning.
1016 *
@@ -16,9 +22,11 @@ let current: WebdriverIO.Browser | undefined;
1622 * subsequent spec cascades. Force-killing the app frees the port and tears down
1723 * the stuck dialog, so each spec gets a clean instance.
1824 *
19- * Runs only in teardown (closeBrowser), so a spec's app is always reclaimed
20- * before the next spec launches its own — without adding latency to the tight
21- * beforeAll/newBrowser budget. No-op on macOS/Linux: they dismiss the dialog
25+ * Runs in teardown (closeBrowser) and, defensively, again right before the next
26+ * spec connects (newBrowser) — teardown alone has two holes that still poison
27+ * the next launch: taskkill returns before the OS releases :4445, and a spec
28+ * whose own beforeAll timed out never set `current`, so its closeBrowser was a
29+ * no-op and nothing was reclaimed. No-op on macOS/Linux: they dismiss the dialog
2230 * reliably, those CI rows don't exhibit the cascade, and a name-based kill
2331 * there could take out a developer's `tauri dev` instance during a local run.
2432 */
@@ -35,7 +43,49 @@ async function forceKillApp(): Promise<void> {
3543 } ) ;
3644}
3745
46+ /**
47+ * Resolves true when nothing is listening on the in-app webdriver port — i.e. a
48+ * prior app instance has fully exited and released it. A localhost connect
49+ * either completes or refuses promptly; on the unlikely timeout we treat the
50+ * port as free (no firewall drops loopback) so the wait can't wedge.
51+ */
52+ function isPortFree ( port : number , host = '127.0.0.1' , timeoutMs = 500 ) : Promise < boolean > {
53+ return new Promise ( ( resolve ) => {
54+ const socket = net . connect ( { port, host } ) ;
55+ const done = ( free : boolean ) => {
56+ socket . removeAllListeners ( ) ;
57+ socket . destroy ( ) ;
58+ resolve ( free ) ;
59+ } ;
60+ socket . setTimeout ( timeoutMs ) ;
61+ socket . once ( 'connect' , ( ) => done ( false ) ) ; // something is still listening
62+ socket . once ( 'timeout' , ( ) => done ( true ) ) ;
63+ socket . once ( 'error' , ( ) => done ( true ) ) ; // ECONNREFUSED → free
64+ } ) ;
65+ }
66+
67+ async function waitForPortFree ( port : number , capMs = 6000 ) : Promise < void > {
68+ const deadline = Date . now ( ) + capMs ;
69+ while ( Date . now ( ) < deadline ) {
70+ if ( await isPortFree ( port ) ) return ;
71+ await new Promise ( ( r ) => setTimeout ( r , 250 ) ) ;
72+ }
73+ // Best-effort: fall through after the cap. remote() then proceeds as it did
74+ // before this guard existed — no worse than the prior behaviour.
75+ }
76+
3877export async function newBrowser ( opts : RemoteOptions ) : Promise < WebdriverIO . Browser > {
78+ // Pre-launch reclaim (Windows only). Before connecting, kill any app instance
79+ // a prior spec left behind and wait for :4445 to actually free, so this
80+ // session never races a dying instance for the port — the cascade that
81+ // otherwise stalls newSession past the 30s beforeAll ceiling and reds the
82+ // gate. On the first spec there's nothing to kill and the port is already
83+ // free, so this costs ~one refused connect. No-op on macOS/Linux (see
84+ // forceKillApp).
85+ if ( process . platform === 'win32' ) {
86+ await forceKillApp ( ) ;
87+ await waitForPortFree ( IN_APP_WEBDRIVER_PORT ) ;
88+ }
3989 current = await remote ( opts ) ;
4090 return current ;
4191}
0 commit comments