Skip to content

Commit f54d5ae

Browse files
committed
fix(service): box the element expect() actually resolved, not the stale last selector
1 parent e4a39e1 commit f54d5ae

6 files changed

Lines changed: 99 additions & 12 deletions

File tree

‎packages/service/src/assertion-tracker.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export class AssertionTracker {
8181
testUid: this.#ctx.getTestUid(),
8282
stepUid: this.#ctx.getStepUid()
8383
}
84+
// Reset before the matcher resolves its element (WDIO's $ is lazy, so it
85+
// resolves inside the matcher) — only an element resolved during this
86+
// assertion is attributed to it; a value matcher stays blank.
87+
this.#ctx.getCapturer().beginAssertionSelector()
8488
}
8589
this.#assertionDepth++
8690
}

‎packages/service/src/constants.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ export const PAGE_TRANSITION_COMMANDS: string[] = [
1111
'click'
1212
]
1313

14+
// Element-resolution commands. Tracked so an assertion can recover the element
15+
// expect() resolved internally — `expect($('#flash'))` fires one of these below
16+
// the top-level command boundary, where the normal selector tracking skips it.
17+
export const LOCATOR_COMMANDS: string[] = [
18+
'$',
19+
'$$',
20+
'findElement',
21+
'findElements'
22+
]
23+
1424
// Console capture constants are defined in @wdio/devtools-core; re-exported
1525
// here so existing imports from ./constants.js continue to work.
1626
export {

‎packages/service/src/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
import {
6767
CONTEXT_CHANGE_COMMANDS,
6868
INTERNAL_COMMANDS,
69+
LOCATOR_COMMANDS,
6970
PAGE_TRANSITION_COMMANDS
7071
} from './constants.js'
7172
import { isNativeMobile } from './mobile.js'
@@ -788,6 +789,18 @@ export default class DevToolsHookService implements Services.ServiceInstance {
788789
return
789790
}
790791

792+
// Record every element resolution, even those below the top-level command
793+
// boundary — an `expect($('#flash'))` resolves its element inside the
794+
// matcher, so this is the only place the assertion's target selector is
795+
// observable (see SessionCapturer.noteResolvedSelector).
796+
if (
797+
LOCATOR_COMMANDS.includes(command as string) &&
798+
typeof args[0] === 'string' &&
799+
args[0]
800+
) {
801+
this.#sessionCapturer.noteResolvedSelector(args[0])
802+
}
803+
791804
/* Ensure that the command is captured only if it matches the last command in the stack.
792805
* This prevents capturing commands that are not top-level user commands.
793806
*/

‎packages/service/src/session.ts‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { resolve } from 'import-meta-resolve'
77
import { SevereServiceError } from 'webdriverio'
88
import type { WebDriverCommands } from '@wdio/protocols'
99

10-
import { PAGE_TRANSITION_COMMANDS } from './constants.js'
10+
import { PAGE_TRANSITION_COMMANDS, LOCATOR_COMMANDS } from './constants.js'
1111
import { isNativeMobile } from './mobile.js'
1212
import {
1313
CAPTURE_PERFORMANCE_SCRIPT,
@@ -30,6 +30,11 @@ export class SessionCapturer extends SessionCapturerBase {
3030
readonly startWallTime = Date.now()
3131
/** Last find-element selector — carried forward to the next element command. */
3232
#lastSelector: string | undefined
33+
/** Selector resolved during the current assertion. Unlike #lastSelector this
34+
* updates on EVERY locator command (even below the top-level boundary), so
35+
* it captures the element `expect($('#flash'))` resolves inside the matcher.
36+
* Cleared at each matcher start so a value assertion inherits nothing. */
37+
#lastResolvedSelector: string | undefined
3338
/** Collapses internal command retries onto a single entry (see #captureOrReplace). */
3439
#retryTracker = new RetryTracker()
3540
#pendingNetworkRequests = new Map<
@@ -55,6 +60,21 @@ export class SessionCapturer extends SessionCapturerBase {
5560
*/
5661
resetLastSelector(): void {
5762
this.#lastSelector = undefined
63+
this.#lastResolvedSelector = undefined
64+
}
65+
66+
/** Record the selector of an element-resolution command, ungated by the
67+
* top-level filter — the service calls this for every locator command so an
68+
* assertion can recover the element expect() resolved internally. */
69+
noteResolvedSelector(selector: string): void {
70+
this.#lastResolvedSelector = selector
71+
}
72+
73+
/** Clear the resolved selector at a matcher's start, so only an element
74+
* resolved DURING this matcher (an element assertion) is attributed to it —
75+
* a value assertion (`expect(x).toBe(y)`) resolves nothing and stays blank. */
76+
beginAssertionSelector(): void {
77+
this.#lastResolvedSelector = undefined
5878
}
5979

6080
protected override onWsError(err: unknown): void {
@@ -146,12 +166,7 @@ export class SessionCapturer extends SessionCapturerBase {
146166
// Track last find-element selector so element commands (click, setValue, …)
147167
// carry a human-readable selector in trace events even though WDIO doesn't
148168
// pass it in their args.
149-
if (
150-
cmd === '$' ||
151-
cmd === '$$' ||
152-
cmd === 'findElement' ||
153-
cmd === 'findElements'
154-
) {
169+
if (LOCATOR_COMMANDS.includes(cmd)) {
155170
const sel = args[0]
156171
if (typeof sel === 'string' && sel.length > 0) {
157172
this.#lastSelector = sel
@@ -253,8 +268,12 @@ export class SessionCapturer extends SessionCapturerBase {
253268
}
254269

255270
/** Ingest an assertion entry (node:assert capture or synthesized expect
256-
* failure) through the same retry-collapsing path driver commands use. */
271+
* failure) through the same retry-collapsing path driver commands use. A
272+
* fresh (unfolded) expect row still carries the element it targeted. */
257273
captureAssertCommand(entry: CommandLog): void {
274+
if (!entry.selector) {
275+
entry.selector = this.#lastResolvedSelector
276+
}
258277
this.#captureOrReplace(entry)
259278
}
260279

@@ -296,10 +315,10 @@ export class SessionCapturer extends SessionCapturerBase {
296315
callSource: entry.callSource ?? last.callSource,
297316
screenshot: entry.screenshot ?? last.screenshot,
298317
error: entry.error ?? last.error,
299-
// The matcher's `args` become the expected value on fold, so the read's
300-
// locator is the only record of which element the assertion targeted —
301-
// carry it so the player's overlay can box e.g. `#flash`.
302-
selector: entry.selector ?? last.selector ?? this.#lastSelector
318+
// The matcher's `args` become the expected value on fold, so record which
319+
// element the assertion targeted — the selector expect() resolved during
320+
// this matcher — so the player's overlay can box e.g. `#flash`.
321+
selector: entry.selector ?? this.#lastResolvedSelector
303322
}
304323
log[log.length - 1] = merged
305324
this.sendReplaceCommand(last.timestamp, merged)

‎packages/service/tests/assertion-rows.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const capturer = vi.hoisted(() => ({
2020
cleanup: vi.fn(),
2121
resetLastSelector: vi.fn(),
2222
resetRetryTracker: vi.fn(),
23+
beginAssertionSelector: vi.fn(),
24+
noteResolvedSelector: vi.fn(),
2325
commandsLog: [] as unknown[],
2426
sources: new Map<string, string>()
2527
}))

‎packages/service/tests/session.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,45 @@ describe('SessionCapturer', () => {
159159
})
160160
})
161161

162+
describe('assertion target selector', () => {
163+
const assertEntry = (command: string) =>
164+
({ command, args: [], timestamp: Date.now() }) as any
165+
166+
it('stamps the element resolved during the matcher onto a fresh assert row', () => {
167+
const capturer = new SessionCapturer()
168+
// expect($('#flash')) resolves the element inside the matcher.
169+
capturer.beginAssertionSelector()
170+
capturer.noteResolvedSelector('#flash')
171+
capturer.captureAssertCommand(assertEntry('expect.toExist'))
172+
expect(capturer.commandsLog.at(-1)!.selector).toBe('#flash')
173+
})
174+
175+
it('folds the resolved selector into the matcher read', () => {
176+
const capturer = new SessionCapturer()
177+
capturer.commandsLog.push(assertEntry('getText'))
178+
capturer.beginAssertionSelector()
179+
capturer.noteResolvedSelector('#flash')
180+
const folded = capturer.coalesceAssertionIntoLastRead(
181+
{
182+
command: 'expect.toHaveText',
183+
args: ['hi'],
184+
timestamp: Date.now()
185+
} as any,
186+
(c) => c === 'getText'
187+
)
188+
expect(folded).toBe(true)
189+
expect(capturer.commandsLog.at(-1)!.selector).toBe('#flash')
190+
})
191+
192+
it('leaves a value assertion blank (no element resolved during it)', () => {
193+
const capturer = new SessionCapturer()
194+
capturer.noteResolvedSelector('#flash') // stale, from a prior assertion
195+
capturer.beginAssertionSelector() // expect(x).toBe(y) resolves nothing
196+
capturer.captureAssertCommand(assertEntry('expect.toBe'))
197+
expect(capturer.commandsLog.at(-1)!.selector).toBeUndefined()
198+
})
199+
})
200+
162201
describe('websocket communication', () => {
163202
it('should handle connection states and errors', () => {
164203
const mockWs = {

0 commit comments

Comments
 (0)