Skip to content

Commit 47225b3

Browse files
committed
Service+Nightwatch withTimeout timer leak
1 parent 72a8894 commit 47225b3

8 files changed

Lines changed: 40 additions & 43 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ The zip contains:
8282
- `trace.trace` — NDJSON `context-options` + `before`/`after` action events
8383
- `trace.network` — HAR-style network entries derived from the existing capture
8484
- `resources/page@<id>-<ts>.jpeg` — screenshot per user-facing action
85-
- `resources/elements-page@<id>-<ts>.json` — flat interactable element list from `@wdio/elements`
85+
- `resources/elements-page@<id>-<ts>.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`
8686
- `resources/snapshot-page@<id>-<ts>.txt` — depth-indented accessibility-tree snapshot (AI-friendly)
8787

8888
What counts as a user-facing action is filtered through an allow-list in `@wdio/devtools-core/action-mapping.ts` (`url`, `click`, `setValue`, `sendKeys`, `get`, etc.). Internal commands like `findElement`/`waitUntil`/`executeScript` don't produce trace entries.
@@ -95,7 +95,7 @@ Trace mode and live mode are **mutually exclusive** — `screencast` options are
9595

9696
#### 📱 Mobile testing
9797

98-
Adapters detect mobile sessions via `platformName: 'android' | 'ios'` (and Appium `bstack:options`) and adjust the per-action snapshot to extract elements from the mobile XML tree instead of the DOM. The trace's `context-options` records `title: 'android'` / `'ios'` so the viewer labels frames correctly.
98+
Adapters detect mobile sessions via `platformName: 'android' | 'ios'` (case-insensitive) and adjust the per-action snapshot to extract elements from the mobile XML tree instead of the DOM. The trace's `context-options` records `title: 'android' — <deviceName>` / `'ios' — <deviceName>` so the viewer labels frames correctly.
9999

100100
A reference WDIO config is at [examples/wdio/wdio.mobile.conf.ts](examples/wdio/wdio.mobile.conf.ts). Prereqs to run it end-to-end with a local emulator:
101101

‎examples/nightwatch/nightwatch.conf.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ module.exports = {
4545
// off to avoid duplicate entries.
4646
globals: nightwatchDevtools({
4747
port: 3000,
48-
mode: 'trace',
48+
mode: 'live',
4949
screencast: { enabled: true, pollIntervalMs: 200 },
5050
bidi: true
5151
})

‎examples/wdio/wdio.conf.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,15 @@ export const config: Options.Testrunner = {
127127
// Services take over a specific job you don't want to take care of. They enhance
128128
// your test setup with almost no effort. Unlike plugins, they don't add new
129129
// commands. Instead, they hook themselves up into the test process.
130-
services: [['devtools', { mode: 'trace' as const }]],
130+
services: [
131+
[
132+
'devtools',
133+
{
134+
mode: 'live' as const,
135+
screencast: { enabled: true, pollIntervalMs: 200 }
136+
}
137+
]
138+
],
131139
//
132140
// Framework you want to run your specs with.
133141
// The following are supported: Mocha, Jasmine, and Cucumber

‎packages/core/src/with-timeout.ts‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ export function withTimeout<T>(
1212
const timeout = new Promise<T>((resolve) => {
1313
timer = setTimeout(() => resolve(fallback), ms)
1414
})
15-
return Promise.race([
16-
promise.then((v) => {
17-
if (timer) {
18-
clearTimeout(timer)
19-
}
20-
return v
21-
}),
22-
timeout
23-
])
15+
// .finally on the race result clears the timer in BOTH the resolved AND
16+
// rejected branch — earlier code only cleared on fulfillment, leaving a
17+
// dangling timer per failed probe that delayed process exit by up to `ms`.
18+
return Promise.race([promise, timeout]).finally(() => {
19+
if (timer) {
20+
clearTimeout(timer)
21+
}
22+
})
2423
}
2524

2625
/** Default ceiling for a single in-page snapshot probe. */

‎packages/nightwatch-devtools/src/session.ts‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -456,17 +456,11 @@ export class SessionCapturer extends SessionCapturerBase {
456456
// Capture network requests from Chrome performance logs
457457
await this.captureNetworkFromPerformanceLogs(browser)
458458

459-
// Also try the injected wdioTraceCollector script for XHR/fetch and mutations
459+
// Also try the injected wdioTraceCollector script for XHR/fetch and mutations.
460+
// Atomic check+read — the inline `typeof === 'undefined' → null` guard is
461+
// the only safe form; a separate existence check would race page navigation
462+
// (the collector can disappear between the two round-trips).
460463
try {
461-
const checkResult = await browser.execute(
462-
'return typeof window.wdioTraceCollector !== "undefined"'
463-
)
464-
const collectorExists = unwrapDriverValue<unknown>(checkResult) === true
465-
466-
if (!collectorExists) {
467-
return
468-
}
469-
470464
const result = await browser.execute(`
471465
if (typeof window.wdioTraceCollector === 'undefined') {
472466
return null;

‎packages/nightwatch-devtools/tests/session.test.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,8 @@ describe('SessionCapturer.captureTrace', () => {
314314
getLog: vi.fn(async () => []),
315315
execute: vi.fn(async () => {
316316
call++
317-
if (call === 1) {
318-
// collector check
319-
return { value: true }
320-
}
321-
// getTraceData
317+
// Single atomic check+read — the inline `typeof === 'undefined' → null`
318+
// guard in the script body avoids the navigation TOCTOU race.
322319
return {
323320
value: {
324321
mutations: [
@@ -332,6 +329,6 @@ describe('SessionCapturer.captureTrace', () => {
332329
})
333330
const cap = makeCapturer(browser)
334331
await cap.captureTrace(browser)
335-
expect(call).toBe(2)
332+
expect(call).toBe(1)
336333
})
337334
})

‎packages/selenium-devtools/tests/session.test.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,20 +221,18 @@ describe('selenium SessionCapturer (with stashed executeScript)', () => {
221221
let call = 0
222222
stubExec(async () => {
223223
call++
224-
return call === 1 ? true : null
224+
return null
225225
})
226226
const cap = makeCapturer({ id: 'd' })
227227
await expect(cap.captureTrace()).resolves.toBeUndefined()
228-
expect(call).toBe(2)
228+
// Single atomic check+read in one executeScript — see session.ts comment.
229+
expect(call).toBe(1)
229230
})
230231

231232
it('captureTrace processes payload when collector returns data', async () => {
232233
let call = 0
233234
stubExec(async () => {
234235
call++
235-
if (call === 1) {
236-
return true
237-
}
238236
return {
239237
mutations: [],
240238
networkRequests: [],
@@ -243,7 +241,7 @@ describe('selenium SessionCapturer (with stashed executeScript)', () => {
243241
})
244242
const cap = makeCapturer({ id: 'd' })
245243
await cap.captureTrace()
246-
expect(call).toBe(2)
244+
expect(call).toBe(1)
247245
})
248246

249247
it('captureTrace swallows ECONNREFUSED / no-such-session errors silently', async () => {

‎packages/service/src/session.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,20 +207,21 @@ export class SessionCapturer extends SessionCapturerBase {
207207
}
208208

209209
try {
210-
const collectorExists = await browser.execute(
211-
() => typeof window.wdioTraceCollector !== 'undefined'
210+
// Atomic check+read in a single browser.execute so the collector can't
211+
// disappear (page navigation) between the existence check and the
212+
// getTraceData call. Two round-trips left a TOCTOU race that surfaced
213+
// spurious "Cannot read properties of undefined" errors.
214+
const payload = await browser.execute(() =>
215+
typeof window.wdioTraceCollector !== 'undefined'
216+
? window.wdioTraceCollector.getTraceData()
217+
: null
212218
)
213-
214-
if (!collectorExists) {
219+
if (!payload) {
215220
log.warn(
216221
'wdioTraceCollector not loaded yet - page loaded before preload script took effect'
217222
)
218223
return
219224
}
220-
221-
const payload = await browser.execute(() =>
222-
window.wdioTraceCollector.getTraceData()
223-
)
224225
this.processTracePayload(payload as Record<string, unknown>)
225226
} catch (err) {
226227
log.error(`Failed to capture trace: ${errorMessage(err)}`)

0 commit comments

Comments
 (0)