Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nightwatch-perf-read-off-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/nightwatch-devtools': patch
---

Stop the plugin from cutting a test's command short. Half a second after a navigation the plugin reads the page's performance timings, and it did so through Nightwatch's command queue, which attached the read to whichever command the test was running at the time and let that command's `await` return before the command had finished. A login that awaited `setValue` on the password field could then click submit while the password was still being typed. The read now goes to the driver directly.
7 changes: 7 additions & 0 deletions .changeset/performance-read-returns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@wdio/devtools-service': patch
'@wdio/selenium-devtools': patch
'@wdio/nightwatch-devtools': patch
---

Navigation rows carry their performance timings again. The script that reads them ran in the browser but never returned its result to the adapter, so every navigation row was left without load timings, cookies or document info.
2 changes: 1 addition & 1 deletion packages/core/src/performance-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
* Framework-agnostic — all three adapters can use it.
*/
export const CAPTURE_PERFORMANCE_SCRIPT = `
(function() {
return (function() {
const performance = window.performance;
const navigation = performance.getEntriesByType?.('navigation')?.[0];
const resources = performance.getEntriesByType?.('resource') || [];
Expand Down
48 changes: 48 additions & 0 deletions packages/core/tests/performance-capture.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
applyPerformanceData,
CAPTURE_PERFORMANCE_SCRIPT,
type CapturedPerformancePayload
} from '../src/performance-capture.js'
import type { CommandLog } from '@wdio/devtools-shared'
Expand Down Expand Up @@ -58,3 +59,50 @@ describe('applyPerformanceData', () => {
expect((cmd.result as Record<string, unknown>).resourceCount).toBe(1)
})
})

describe('CAPTURE_PERFORMANCE_SCRIPT', () => {
// WebDriver's execute runs the string as a function body, so the body itself must return the payload.
it('returns the payload when run as a function body', () => {
const navigation = {
fetchStart: 0,
loadEventEnd: 120,
domContentLoadedEventEnd: 80,
requestStart: 10,
responseStart: 20,
responseEnd: 30,
domainLookupStart: 1,
domainLookupEnd: 2,
connectStart: 3,
connectEnd: 4
}
const window = {
location: { href: 'https://site.test/login' },
performance: {
getEntriesByType: (type: string) =>
type === 'navigation' ? [navigation] : []
}
}
const document = {
cookie: '',
title: 'Login',
readyState: 'complete',
referrer: '',
characterSet: 'UTF-8'
}
const navigator = { userAgent: 'ua', language: 'en', platform: 'p' }
const run = new Function(
'window',
'document',
'navigator',
CAPTURE_PERFORMANCE_SCRIPT
)
const payload = run(
window,
document,
navigator
) as CapturedPerformancePayload
expect(payload?.navigation?.url).toBe('https://site.test/login')
expect(payload?.navigation?.timing.loadTime).toBe(120)
expect(applyPerformanceData(freshCommand(), payload)).toBe(true)
})
})
2 changes: 2 additions & 0 deletions packages/nightwatch-devtools/src/helpers/webdriverHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ export function resolveWebDriverAddress(browser: NightwatchBrowser): {
* speaks plain http to a local driver — it has no cloud-grid path of its own. */
function address(browser: NightwatchBrowser): WebDriverAddress {
const { driverHost, driverPort } = resolveWebDriverAddress(browser)
const { pathPrefix } = configuredWebDriver(browser)
return {
hostname: driverHost,
port: driverPort,
path: typeof pathPrefix === 'string' ? pathPrefix : undefined,
onWarn: (message: string) => log.warn(message)
}
}
Expand Down
24 changes: 16 additions & 8 deletions packages/nightwatch-devtools/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,23 @@ export class SessionCapturer extends SessionCapturerBase {
return
}
await new Promise((resolve) => setTimeout(resolve, 500))
const raw = await this.#browser!.execute(CAPTURE_PERFORMANCE_SCRIPT)
const payload = unwrapDriverValue<CapturedPerformancePayload | undefined>(
raw
)
applyPerformanceData(
commandLogEntry,
payload,
typeof args[0] === 'string' ? args[0] : undefined
// A queued `browser.execute` fired from this timer becomes a child of whatever command is running, and resolves that command's await early.
const payload = await webdriverExecute<CapturedPerformancePayload>(
this.#browser!,
CAPTURE_PERFORMANCE_SCRIPT
)
Comment thread
vishnuv688 marked this conversation as resolved.
Resolved
if (
applyPerformanceData(
commandLogEntry,
payload ?? undefined,
typeof args[0] === 'string' ? args[0] : undefined
) &&
// A retry may have replaced the row meanwhile; replacing it again would bring the old row back.
this.commandsLog.includes(commandLogEntry)
) {
// The row was sent when the command completed; the timings arrive half a second later.
this.sendReplaceCommand(commandLogEntry.timestamp, commandLogEntry)
Comment thread
vishnuv688 marked this conversation as resolved.
Resolved
}
}

/**
Expand Down
87 changes: 80 additions & 7 deletions packages/nightwatch-devtools/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,87 @@ describe('SessionCapturer.captureCommand', () => {
expect(stored.message).toBe('boom')
})

it('triggers performance capture for navigation commands when browser present', async () => {
const execute = vi.fn(async () => ({ value: undefined }))
const browser = makeMockBrowser({ execute })
// The read fires from a timer while a later command is running; queued through
// `browser.execute` it became that command's child and resolved its await early,
// so a test's next `click` ran while a `setValue` was still typing.
it('reads performance data after a navigation outside the command queue', async () => {
const http = await import('node:http')
const bodies: string[] = []
const server = http.createServer((req, res) => {
let raw = ''
req.on('data', (c) => {
raw += c
})
req.on('end', () => {
bodies.push(raw)
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
value: { navigation: { url: 'https://x', timing: {} } }
})
)
})
})
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
const port = (server.address() as { port: number }).port
const browser = makeMockBrowser({
sessionId: 'sess-perf',
transport: { settings: { webdriver: { host: '127.0.0.1', port } } }
})
const cap = makeCapturer(browser)
await cap.captureCommand('url', ['https://x'], undefined, undefined)
// perf capture runs in background after a 500ms delay; let it settle
await new Promise((r) => setTimeout(r, 600))
expect(execute).toHaveBeenCalled()
const send = vi.spyOn(cap, 'sendUpstream')
try {
await cap.captureCommand('url', ['https://x'], undefined, undefined)
await vi.waitFor(() => expect(bodies).toHaveLength(1), { timeout: 2000 })
expect(bodies[0]).toContain('getEntriesByType')
expect(browser.execute).not.toHaveBeenCalled()
// The timings reach the dashboard by replacing the row already sent.
await vi.waitFor(() =>
expect(send).toHaveBeenCalledWith(
'replaceCommand',
expect.objectContaining({
command: expect.objectContaining({ performance: expect.anything() })
})
)
)
} finally {
await new Promise<void>((r) => server.close(() => r()))
}
})

it('sends no timings for a navigation row a retry has replaced', async () => {
const http = await import('node:http')
let reads = 0
const server = http.createServer((req, res) => {
req.resume()
req.on('end', () => {
reads++
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
value: { navigation: { url: 'https://x', timing: {} } }
})
)
})
})
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
const port = (server.address() as { port: number }).port
const browser = makeMockBrowser({
sessionId: 'sess-perf',
transport: { settings: { webdriver: { host: '127.0.0.1', port } } }
})
const cap = makeCapturer(browser)
const send = vi.spyOn(cap, 'sendUpstream')
try {
await cap.captureCommand('url', ['https://x'], undefined, undefined)
const firstId = (cap.commandsLog[0] as { _id?: number })._id ?? -1
cap.replaceCommand(firstId, 'url', ['https://x'], undefined, undefined)
await vi.waitFor(() => expect(reads).toBe(1), { timeout: 2000 })
await new Promise((r) => setTimeout(r, 50))
expect(send).not.toHaveBeenCalledWith('replaceCommand', expect.anything())
} finally {
await new Promise<void>((r) => server.close(() => r()))
}
})

it('skips performance capture when error present', async () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/nightwatch-devtools/tests/sessionExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { describe, it, expect, vi } from 'vitest'
import type * as DevToolsCore from '@wdio/devtools-core'
import { sessionExecutor } from '../src/helpers/webdriverHttp.js'
import {
sessionExecutor,
webdriverExecute as executeInPage
} from '../src/helpers/webdriverHttp.js'
import type { NightwatchBrowser } from '../src/types.js'

const webdriverExecute = vi.hoisted(() => vi.fn(async () => 'L1'))
const webdriverPost = vi.hoisted(() => vi.fn(async () => null))

vi.mock('@wdio/devtools-core', async (importOriginal) => ({
...(await importOriginal<typeof DevToolsCore>()),
webdriverExecute
webdriverExecute,
webdriverPost
}))

const browser = (
Expand Down Expand Up @@ -52,3 +57,19 @@ describe('sessionExecutor', () => {
expect(sessionExecutor(browser('sess-1', {}))).toBeUndefined()
})
})

// Regression: the probes, drains and performance read dropped the prefix, so a driver under `/wd/hub` rejected every one.
describe('the direct transport', () => {
it('carries the configured path prefix', async () => {
await executeInPage(
browser('sess-1', { port: 4444, default_path_prefix: '/wd/hub' }),
'return 1'
)
expect(webdriverPost).toHaveBeenLastCalledWith(
expect.objectContaining({ port: 4444, path: '/wd/hub' }),
'sess-1',
'execute/sync',
{ script: 'return 1', args: [] }
)
})
})
2 changes: 1 addition & 1 deletion packages/service/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export class SessionCapturer extends SessionCapturerBase {
const payload = (await browser.execute(CAPTURE_PERFORMANCE_SCRIPT)) as
CapturedPerformancePayload | undefined
if (applyPerformanceData(entry, payload, args[0] as string | undefined)) {
this.sendUpstream('commands', [entry])
this.sendReplaceCommand(entry.timestamp, entry)
}
} catch (err) {
const msg = errorMessage(err)
Expand Down
36 changes: 36 additions & 0 deletions packages/service/tests/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { CAPTURE_PERFORMANCE_SCRIPT } from '@wdio/devtools-core'
import type { CommandLog } from '@wdio/devtools-shared'
import { SessionCapturer } from '../src/session.js'
import { WebSocket } from 'ws'
Expand Down Expand Up @@ -1295,6 +1296,41 @@ describe('captureTrace on a native session', () => {
).toHaveBeenCalled()
})

// Regression: the timings were sent as a new command, so every navigation was listed twice.
it('replaces the navigation row with its performance timings', async () => {
const browser = webBrowser()
const execute = (
browser as unknown as { execute: ReturnType<typeof vi.fn> }
).execute
execute.mockImplementation(async (script: unknown) =>
script === CAPTURE_PERFORMANCE_SCRIPT
? { navigation: { url: 'https://example.com', timing: {} } }
: null
)
const capturer = new SessionCapturer()
const send = vi.spyOn(capturer, 'sendUpstream' as any)

await capturer.afterCommand(
browser,
'navigateTo',
['https://example.com'],
undefined,
undefined
)

const scopes = send.mock.calls.map(([scope]) => scope)
expect(scopes.filter((s) => s === 'commands')).toHaveLength(1)
expect(send).toHaveBeenCalledWith(
'replaceCommand',
expect.objectContaining({
command: expect.objectContaining({
command: 'navigateTo',
performance: expect.anything()
})
})
)
})

it('makes no page call after a transition on a native app', async () => {
const browser = nativeBrowser()

Expand Down
Loading