Skip to content

Commit 14f6955

Browse files
committed
Snapshot feature
1 parent 2c158a9 commit 14f6955

9 files changed

Lines changed: 300 additions & 60 deletions

File tree

example/wdio.conf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
6363
capabilities: [
6464
{
6565
browserName: 'chrome',
66-
browserVersion: '145.0.7632.160', // specify chromium browser version for testing
66+
browserVersion: '146.0.7680.72', // specify chromium browser version for testing
6767
'goog:chromeOptions': {
6868
args: [
6969
'--headless',

packages/app/src/components/browser/snapshot.ts

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
mutationContext,
1212
type TraceMutation,
1313
metadataContext,
14-
type Metadata
14+
type Metadata,
15+
commandContext
1516
} from '../../controller/DataManager.js'
1617

1718
import '~icons/mdi/world.js'
@@ -20,15 +21,16 @@ import '../placeholder.js'
2021
const MUTATION_SELECTOR = '__mutation-highlight__'
2122

2223
function transform(node: any): VNode<{}> {
23-
if (typeof node !== 'object') {
24+
if (typeof node !== 'object' || node === null) {
25+
// Plain string/number text node — return as-is for Preact to render as text.
2426
return node as VNode<{}>
2527
}
2628

27-
const { children, ...props } = node.props
29+
const { children, ...props } = node.props ?? {}
2830
/**
2931
* ToDo(Christian): fix way we collect data on added nodes in script
3032
*/
31-
if (!node.type && children.type) {
33+
if (!node.type && children?.type) {
3234
return transform(children)
3335
}
3436

@@ -44,13 +46,18 @@ const COMPONENT = 'wdio-devtools-browser'
4446
export class DevtoolsBrowser extends Element {
4547
#vdom = document.createDocumentFragment()
4648
#activeUrl?: string
49+
/** Base64 PNG of the screenshot for the currently selected command, or null. */
50+
#screenshotData: string | null = null
4751

4852
@consume({ context: metadataContext, subscribe: true })
4953
metadata: Metadata | undefined = undefined
5054

5155
@consume({ context: mutationContext, subscribe: true })
5256
mutations: TraceMutation[] = []
5357

58+
@consume({ context: commandContext, subscribe: true })
59+
commands: CommandLog[] = []
60+
5461
static styles = [
5562
...Element.styles,
5663
css`
@@ -112,6 +119,31 @@ export class DevtoolsBrowser extends Element {
112119
border-radius: 0 0 0.5rem 0.5rem;
113120
min-height: 0;
114121
}
122+
123+
.screenshot-overlay {
124+
position: absolute;
125+
inset: 0;
126+
background: #111;
127+
display: flex;
128+
align-items: flex-start;
129+
justify-content: center;
130+
border-radius: 0 0 0.5rem 0.5rem;
131+
overflow: hidden;
132+
}
133+
134+
.screenshot-overlay img {
135+
max-width: 100%;
136+
height: auto;
137+
display: block;
138+
}
139+
140+
.iframe-wrapper {
141+
position: relative;
142+
flex: 1;
143+
min-height: 0;
144+
display: flex;
145+
flex-direction: column;
146+
}
115147
`
116148
]
117149

@@ -148,9 +180,16 @@ export class DevtoolsBrowser extends Element {
148180
return
149181
}
150182

183+
// viewport may not be serialized yet (race between metadata message and
184+
// first resize event), or may arrive without dimensions — fall back to
185+
// sensible defaults so we never throw.
186+
const viewportWidth = (metadata.viewport as any)?.width || 1280
187+
const viewportHeight = (metadata.viewport as any)?.height || 800
188+
if (!viewportWidth || !viewportHeight) {
189+
return
190+
}
191+
151192
this.iframe.removeAttribute('style')
152-
const viewportWidth = metadata.viewport.width
153-
const viewportHeight = metadata.viewport.height
154193
const frameSize = this.getBoundingClientRect()
155194
const headerSize = this.header.getBoundingClientRect()
156195

@@ -180,21 +219,13 @@ export class DevtoolsBrowser extends Element {
180219
async #renderCommandScreenshot(command?: CommandLog) {
181220
const screenshot = command?.screenshot
182221
if (!screenshot) {
222+
// Clicking a command that has no screenshot clears any previous overlay.
223+
this.#screenshotData = null
224+
this.requestUpdate()
183225
return
184226
}
185-
186-
if (!this.iframe) {
187-
await this.updateComplete
188-
}
189-
if (!this.iframe) {
190-
return
191-
}
192-
193-
this.iframe.srcdoc = `
194-
<body style="margin:0;background:#111;display:flex;justify-content:center;align-items:flex-start;">
195-
<img src="data:image/png;base64,${screenshot}" style="max-width:100%;height:auto;display:block;" />
196-
</body>
197-
`
227+
this.#screenshotData = screenshot
228+
this.requestUpdate()
198229
}
199230

200231
async #renderNewDocument(doc: SimplifiedVNode, baseUrl: string) {
@@ -270,7 +301,11 @@ export class DevtoolsBrowser extends Element {
270301

271302
#handleChildListMutation(mutation: TraceMutation) {
272303
if (mutation.addedNodes.length === 1 && !mutation.target) {
273-
const baseUrl = this.metadata?.url || 'unknown'
304+
// Prefer the URL embedded in the mutation itself (set by the injected script
305+
// at capture time), then fall back to the already-resolved active URL, and
306+
// finally to the context metadata URL. This avoids a race where metadata
307+
// arrives after the first childList mutation fires #renderNewDocument.
308+
const baseUrl = mutation.url || this.#activeUrl || this.metadata?.url || 'unknown'
274309
this.#renderNewDocument(
275310
mutation.addedNodes[0] as SimplifiedVNode,
276311
baseUrl
@@ -389,6 +424,15 @@ export class DevtoolsBrowser extends Element {
389424
this.requestUpdate()
390425
}
391426

427+
/** Latest screenshot from any command — auto-updates the preview as tests run. */
428+
get #latestAutoScreenshot(): string | null {
429+
if (!this.commands?.length) return null
430+
for (let i = this.commands.length - 1; i >= 0; i--) {
431+
if (this.commands[i].screenshot) return this.commands[i].screenshot!
432+
}
433+
return null
434+
}
435+
392436
render() {
393437
/**
394438
* render a browser state if it hasn't before
@@ -398,6 +442,12 @@ export class DevtoolsBrowser extends Element {
398442
this.#renderBrowserState()
399443
}
400444

445+
const hasMutations = this.mutations && this.mutations.length
446+
// Explicit user selection takes priority; fall back to latest auto-screenshot
447+
// so the preview always shows the most recently executed command's state
448+
// (important for Nightwatch mode where there are no DOM mutations).
449+
const displayScreenshot = this.#screenshotData ?? this.#latestAutoScreenshot
450+
401451
return html`
402452
<section
403453
class="w-full h-full bg-sideBarBackground rounded-lg border-2 border-panelBorder shadow-xl"
@@ -417,11 +467,26 @@ export class DevtoolsBrowser extends Element {
417467
<span class="truncate">${this.#activeUrl}</span>
418468
</div>
419469
</header>
420-
${this.mutations && this.mutations.length
421-
? html`<iframe class="origin-top-left"></iframe>`
422-
: html`<wdio-devtools-placeholder
423-
style="height: 100%"
424-
></wdio-devtools-placeholder>`}
470+
${hasMutations
471+
? html`
472+
<div class="iframe-wrapper">
473+
<iframe class="origin-top-left"></iframe>
474+
${displayScreenshot
475+
? html`<div class="screenshot-overlay">
476+
<img src="data:image/png;base64,${displayScreenshot}" />
477+
</div>`
478+
: ''}
479+
</div>`
480+
: displayScreenshot
481+
? html`
482+
<div class="iframe-wrapper">
483+
<div class="screenshot-overlay" style="position:relative;flex:1;min-height:0;">
484+
<img src="data:image/png;base64,${displayScreenshot}" />
485+
</div>
486+
</div>`
487+
: html`<wdio-devtools-placeholder
488+
style="height: 100%"
489+
></wdio-devtools-placeholder>`}
425490
</section>
426491
`
427492
}

packages/app/src/components/workbench/actions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ export class DevtoolsActions extends Element {
4444
render() {
4545
const mutations = this.mutations || []
4646
const commands = this.commands || []
47-
const entries = [...mutations, ...commands].sort(
47+
// Only show document-load mutations (childList with a url) in the actions
48+
// list — individual node add/remove mutations are too noisy.
49+
const visibleMutations = mutations.filter(
50+
(m) => m.type === 'childList' && Boolean(m.url)
51+
)
52+
const entries = [...visibleMutations, ...commands].sort(
4853
(a, b) => a.timestamp - b.timestamp
4954
)
5055

packages/nightwatch-devtools/src/constants.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ export const INTERNAL_COMMANDS_TO_IGNORE = [
2525
'perform',
2626
'execute',
2727
'executeAsync',
28-
'executeScript'
28+
'executeScript',
29+
// Internal Nightwatch transport commands (used for log capture, not user actions)
30+
'sessionLog',
31+
'sessionLogTypes',
32+
'isLogAvailable',
33+
'end'
2934
] as const
3035

3136
export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const

packages/nightwatch-devtools/src/helpers/browserProxy.ts

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import logger from '@wdio/logger'
77
import {
88
INTERNAL_COMMANDS_TO_IGNORE,
9-
BOOLEAN_COMMAND_PATTERN
9+
BOOLEAN_COMMAND_PATTERN,
10+
NAVIGATION_COMMANDS
1011
} from '../constants.js'
1112
import { getCallSourceFromStack } from './utils.js'
1213
import type { SessionCapturer } from '../session.js'
@@ -68,33 +69,50 @@ export class BrowserProxy {
6869
}
6970

7071
/**
71-
* Wrap browser.url to inject the DevTools script after every navigation.
72+
* Wrap browser navigation methods (url / navigate / navigateTo) to inject
73+
* the DevTools script after every navigation.
7274
*
73-
* NOTE: This wraps the raw `browser.url` before `wrapBrowserCommands` runs.
74-
* When `wrapBrowserCommands` subsequently wraps this function it will inject
75-
* our result-capturing callback as the last argument. We must forward *all*
76-
* arguments (including that callback) through to the real `originalUrl` so
77-
* that Nightwatch's command queue fires it and we receive the actual result.
75+
* Uses `browser` from the closure (not `this` inside perform) so it works
76+
* for both standard Nightwatch (chainable API) and Cucumber async/await mode
77+
* where `this` inside a perform callback is not the browser.
7878
*/
7979
wrapUrlMethod(browser: NightwatchBrowser): void {
80-
const originalUrl = browser.url.bind(browser)
8180
const sessionCapturer = this.sessionCapturer
8281

83-
browser.url = function (...urlArgs: any[]) {
84-
const result = (originalUrl as any)(...urlArgs) as any
82+
const wrapNav = (methodName: string) => {
83+
if (typeof (browser as any)[methodName] !== 'function') return
84+
const original = (browser as any)[methodName].bind(browser)
8585

86-
if (result && typeof result.perform === 'function') {
87-
result.perform(async function (this: any) {
88-
try {
89-
await sessionCapturer.injectScript(this)
90-
} catch (err) {
91-
log.error(`Failed to inject script: ${(err as Error).message}`)
92-
}
93-
})
86+
;(browser as any)[methodName] = function (...args: any[]) {
87+
const result = original(...args)
88+
89+
const injectAndCapture = () =>
90+
sessionCapturer
91+
.injectScript(browser)
92+
.then(() => sessionCapturer.captureTrace(browser))
93+
.catch((err: Error) =>
94+
log.error(`Failed to inject script: ${err.message}`)
95+
)
96+
97+
if (result && typeof result.perform === 'function') {
98+
// Standard Nightwatch (chained API): queue inside perform so it
99+
// runs after navigation completes. Always pass `done` so the
100+
// command queue is unblocked even if injection fails.
101+
result.perform((done: Function) => {
102+
injectAndCapture().finally(() => done && done())
103+
})
104+
} else {
105+
// Cucumber async/await: result is a Promise (or thenable).
106+
Promise.resolve(result).then(injectAndCapture).catch(() => {})
107+
}
108+
109+
return result
94110
}
111+
}
95112

96-
return result
97-
} as any
113+
wrapNav('url')
114+
wrapNav('navigate')
115+
wrapNav('navigateTo')
98116

99117
log.info('✓ Script injection wrapped')
100118
}
@@ -156,7 +174,7 @@ export class BrowserProxy {
156174
* the command finishes rather than being `undefined`.
157175
*/
158176
private handleCommandExecution(
159-
_browser: NightwatchBrowser,
177+
browser: NightwatchBrowser,
160178
browserAny: any,
161179
methodName: string,
162180
originalMethod: Function,
@@ -285,6 +303,24 @@ export class BrowserProxy {
285303
)
286304
this.lastCapturedId = entry._id ?? null
287305
this.sessionCapturer.sendReplaceCommand(oldTimestamp, entry)
306+
307+
// Snapshot this entry for the screenshot perform below.
308+
const entryToScreenshot = entry
309+
if (typeof (browser as any).perform === 'function') {
310+
const ts = (entryToScreenshot as any).timestamp
311+
;(browser as any).perform((done: Function) => {
312+
this.sessionCapturer
313+
.takeScreenshotViaHttp(browser)
314+
.then((screenshot) => {
315+
if (screenshot) {
316+
;(entryToScreenshot as any).screenshot = screenshot
317+
this.sessionCapturer.sendReplaceCommand(ts, entryToScreenshot)
318+
}
319+
done()
320+
})
321+
.catch(() => done())
322+
})
323+
}
288324
} else {
289325
// New command — capture and track.
290326
// captureCommand() pushes the entry to commandsLog synchronously
@@ -316,6 +352,40 @@ export class BrowserProxy {
316352
this.lastCapturedId = (lastCommand as any)._id ?? null
317353
this.sessionCapturer.sendCommand(lastCommand)
318354
}
355+
356+
// Queue a perform RIGHT HERE (inside captureCallback, where the entry
357+
// is already known) so it inserts immediately after the current command
358+
// — before the next test command and before end().
359+
// We snapshot `lastCommand` into a local const so it can never be
360+
// overwritten by a later captureCallback for a different command.
361+
const entryToScreenshot = lastCommand
362+
if (entryToScreenshot && typeof (browser as any).perform === 'function') {
363+
const ts = (entryToScreenshot as any).timestamp
364+
;(browser as any).perform((done: Function) => {
365+
this.sessionCapturer
366+
.takeScreenshotViaHttp(browser)
367+
.then((screenshot) => {
368+
if (screenshot) {
369+
;(entryToScreenshot as any).screenshot = screenshot
370+
this.sessionCapturer.sendReplaceCommand(ts, entryToScreenshot)
371+
}
372+
done()
373+
})
374+
.catch(() => done())
375+
})
376+
}
377+
378+
// After DOM-mutating commands, re-poll mutations from the injected
379+
// script so the browser preview stays in sync. Use setTimeout to
380+
// run OUTSIDE Nightwatch's current callback stack (safer queue-wise).
381+
const isDomMutating = (NAVIGATION_COMMANDS as readonly string[]).includes(methodName) ||
382+
['click', 'doubleClick', 'rightClick', 'setValue', 'clearValue',
383+
'sendKeys', 'submitForm', 'back', 'forward', 'refresh'].includes(methodName)
384+
if (isDomMutating) {
385+
setTimeout(() => {
386+
this.sessionCapturer.captureTrace(browser).catch(() => {})
387+
}, 200)
388+
}
319389
}
320390
}
321391

0 commit comments

Comments
 (0)