Skip to content

Commit 5c10eb6

Browse files
committed
Refined commands that are shown in Actions tab specific to the test cases
1 parent 902b761 commit 5c10eb6

5 files changed

Lines changed: 170 additions & 64 deletions

File tree

example/features/step-definitions/steps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ When(/^I login with (\w+) and (.+)$/, async (username, password) => {
1818

1919
Then(/^I should see a flash message saying (.*)$/, async (message) => {
2020
await expect(SecurePage.flashAlert).toBeExisting()
21-
await expect(SecurePage.flashAlert).toHaveTextContaining(message)
21+
await expect(SecurePage.flashAlert).toHaveText(message)
2222
await browser.pause(15000)
2323
})
2424

Lines changed: 129 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,149 @@
11
import { Element } from '@core/element'
2-
import { html, css } from 'lit'
3-
import { customElement } from 'lit/decorators.js'
2+
import { html, css, type TemplateResult } from 'lit'
3+
import { customElement, state } from 'lit/decorators.js'
44
import { consume } from '@lit/context'
5-
6-
import { mutationContext, type TraceMutation, commandContext, type CommandLog } from '../../controller/DataManager.js'
7-
8-
import '~icons/mdi/pencil.js'
9-
import '~icons/mdi/family-tree.js'
10-
import '~icons/mdi/alert.js'
11-
import '~icons/mdi/document.js'
12-
import '~icons/mdi/arrow-right.js'
5+
import type { SuiteStats, TestStats, HookStats } from '@wdio/reporter'
6+
import { suiteContext, commandContext, type CommandLog } from '../../controller/DataManager.js'
137

148
import '../placeholder.js'
159
import './actionItems/command.js'
16-
import './actionItems/mutation.js'
10+
import '../../components/sidebar/collapseableEntry.js'
11+
import '~icons/mdi/sync.js'
12+
import '~icons/mdi/play-box-outline.js'
1713

18-
const SOURCE_COMPONENT = 'wdio-devtools-actions'
14+
// A type to represent any high-level item in our action list
15+
type ActionItem = TestStats | HookStats;
1916

20-
@customElement(SOURCE_COMPONENT)
17+
@customElement('wdio-devtools-actions')
2118
export class DevtoolsActions extends Element {
22-
static styles = [...Element.styles, css`
23-
:host {
24-
display: flex;
25-
flex-direction: column;
26-
width: 100%;
27-
}
28-
`]
29-
30-
@consume({ context: mutationContext, subscribe: true })
31-
mutations: TraceMutation[] = []
19+
@consume({ context: suiteContext, subscribe: true })
20+
suites?: Record<string, SuiteStats>[] = []
3221

3322
@consume({ context: commandContext, subscribe: true })
34-
commands: CommandLog[] = []
23+
commands?: CommandLog[] = []
3524

36-
render() {
37-
const mutations = this.mutations || []
38-
const commands = this.commands || []
39-
const entries = [...mutations, ...commands]
40-
.sort((a, b) => a.timestamp - b.timestamp)
25+
@state()
26+
private _expandedItemId: string | null = null
4127

42-
if (!entries.length || !mutations.length) {
43-
return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>`
28+
// Helper to get a flat, chronological list of all hooks and test steps
29+
private _getActionItems(suites: SuiteStats[]): ActionItem[] {
30+
let items: ActionItem[] = []
31+
for (const suite of suites) {
32+
items = items.concat(suite.hooks)
33+
items = items.concat(suite.tests)
34+
if (suite.suites) {
35+
items = items.concat(this._getActionItems(suite.suites))
36+
}
4437
}
38+
// Correctly sort by converting date strings to Date objects first
39+
return items.sort((a, b) => {
40+
const startTimeA = a.start ? new Date(a.start).getTime() : 0;
41+
const startTimeB = b.start ? new Date(b.start).getTime() : 0;
42+
return startTimeA - startTimeB;
43+
});
44+
}
4545

46-
return entries.map((entry) => {
47-
const elapsedTime = entry.timestamp - mutations[0].timestamp
46+
// Renders a "before" or "after" hook
47+
private _renderHook(hook: HookStats): TemplateResult {
48+
return html`
49+
<div class="hook-item">
50+
<icon-mdi-sync class="icon"></icon-mdi-sync>
51+
<span class="title">Hook: "${hook.title}"</span>
52+
<span class="duration">${hook.duration}ms</span>
53+
</div>
54+
`
55+
}
4856
49-
if ('command' in entry) {
50-
return html`
51-
<wdio-devtools-command-item
52-
elapsedTime=${elapsedTime}
53-
.entry=${entry}
54-
></wdio-devtools-command-item>
55-
`
56-
}
57+
// Renders a test step (e.g., "Given..." or an "it" block) as a collapsible entry
58+
private _renderStep(step: TestStats): TemplateResult {
59+
if (!this.commands) return html``
5760
58-
return html`
59-
<wdio-devtools-mutation-item
60-
elapsedTime=${elapsedTime}
61-
.entry=${entry}
62-
></wdio-devtools-mutation-item>
63-
`
64-
})
61+
// Find all low-level commands that were executed during this specific step
62+
const stepCommands = this.commands.filter(cmd => {
63+
const startTime = step.start ? new Date(step.start).getTime() : 0;
64+
const endTime = step.end ? new Date(step.end).getTime() : Infinity;
65+
return cmd.timestamp >= startTime && cmd.timestamp <= endTime;
66+
});
67+
68+
return html`
69+
<wdio-collapsable-entry
70+
.isInitiallyOpen=${this._expandedItemId === step.uid}
71+
@click=${() => this._expandedItemId = this._expandedItemId === step.uid ? null : step.uid}
72+
>
73+
<div slot="summary" class="step-summary">
74+
<icon-mdi-play-box-outline class="icon"></icon-mdi-play-box-outline>
75+
<span class="title">${step.title}</span>
76+
<span class="duration">${step.duration}ms</span>
77+
</div>
78+
<div class="commands">
79+
${stepCommands.length > 0
80+
? stepCommands.map(command => html`<wdio-devtools-command-item .entry=${command}></wdio-devtools-command-item>`)
81+
: html`<div class="no-commands">No commands recorded for this step.</div>`
82+
}
83+
</div>
84+
</wdio-collapsable-entry>
85+
`
6586
}
66-
}
6787

68-
declare global {
69-
interface HTMLElementTagNameMap {
70-
[SOURCE_COMPONENT]: DevtoolsActions
88+
render() {
89+
const allSuites = this.suites ? Object.values(this.suites).flatMap(s => Object.values(s)) : []
90+
const allItems = this._getActionItems(allSuites)
91+
92+
if (allItems.length === 0) {
93+
return html`<wdio-devtools-placeholder>No actions recorded.</wdio-devtools-placeholder>`
94+
}
95+
96+
return html`
97+
<div class="action-list">
98+
${allItems.map(item => 'type' in item && item.type === 'hook'
99+
? this._renderHook(item as HookStats)
100+
: this._renderStep(item as TestStats)
101+
)}
102+
</div>
103+
`
71104
}
105+
106+
static styles = [...Element.styles, css`
107+
:host, .action-list {
108+
width: 100%;
109+
height: 100%;
110+
overflow-y: auto;
111+
}
112+
.hook-item, .step-summary {
113+
display: flex;
114+
align-items: center;
115+
padding: 0.6rem 1rem;
116+
border-bottom: 1px solid var(--vscode-panel-border);
117+
gap: 0.5rem;
118+
font-size: 0.9em;
119+
}
120+
.step-summary {
121+
cursor: pointer;
122+
}
123+
.step-summary:hover {
124+
background-color: var(--vscode-toolbar-hoverBackground);
125+
}
126+
.icon {
127+
width: 1.1rem;
128+
height: 1.1rem;
129+
flex-shrink: 0;
130+
color: var(--vscode-descriptionForeground);
131+
}
132+
.title {
133+
flex-grow: 1;
134+
}
135+
.duration {
136+
font-size: 0.9em;
137+
color: var(--vscode-descriptionForeground);
138+
}
139+
.commands {
140+
padding-left: 2rem;
141+
border-bottom: 1px solid var(--vscode-panel-border);
142+
}
143+
.no-commands {
144+
padding: 0.5rem 1.5rem;
145+
color: var(--vscode-descriptionForeground);
146+
font-style: italic;
147+
}
148+
`]
72149
}

packages/service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@types/ws": "^8.18.1",
4242
"@wdio/globals": "9.17.0",
4343
"@wdio/protocols": "9.16.2",
44+
"devtools": "^8.42.0",
4445
"vite-plugin-dts": "^4.5.4"
4546
},
4647
"peerDependencies": {

packages/service/src/index.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SessionCapturer } from './session.js'
1111
import { TestReporter } from './reporter.js'
1212
import { DevToolsAppLauncher } from './launcher.js'
1313
import { getBrowserObject } from './utils.ts'
14+
import { parse } from 'stack-trace'
1415
import { type TraceLog, TraceType } from './types.ts'
1516

1617
export const launcher = DevToolsAppLauncher
@@ -69,6 +70,12 @@ export default class DevToolsHookService implements Services.ServiceInstance {
6970
#sessionCapturer = new SessionCapturer()
7071
#browser?: WebdriverIO.Browser
7172

73+
/**
74+
* This is used to capture the command stack to ensure that we only capture
75+
* commands that are top-level user commands.
76+
*/
77+
#commandStack: string[] = []
78+
7279
/**
7380
* allows to define the type of data being captured to hint the
7481
* devtools app which data to expect
@@ -102,6 +109,7 @@ export default class DevToolsHookService implements Services.ServiceInstance {
102109
if (isMultiRemote) {
103110
throw new SevereServiceError('The DevTools hook does not support multiremote yet')
104111
}
112+
// this.#sessionCapturer.readStepDefinitions(config)
105113

106114
if ('reporters' in config) {
107115
const self = this
@@ -121,23 +129,42 @@ export default class DevToolsHookService implements Services.ServiceInstance {
121129
}
122130

123131
async beforeCommand(command: string, args: string[]) {
124-
if (!this.#browser) {
125-
return
132+
if (!this.#browser) { return }
133+
134+
// Always inject the script to support iframe detection etc.
135+
await this.#sessionCapturer.injectScript(getBrowserObject(this.#browser))
136+
137+
/**
138+
* propagate url change to devtools app
139+
*/
140+
if (command === 'url') {
141+
this.#sessionCapturer.sendUpstream('metadata', { url: args[0] })
126142
}
127143

128144
/**
129-
* propagate url change to devtools app
145+
* Smart stack filtering to detect top-level user commands
130146
*/
131-
if (this.#browser && command === 'url') {
132-
this.#sessionCapturer.sendUpstream('metadata', { url: args[0] })
147+
const stack = parse(new Error(''))
148+
const source = stack.find((frame) =>
149+
Boolean(frame.getFileName()) &&
150+
!frame.getFileName()?.includes('node_modules')
151+
)
152+
153+
if (source && this.#commandStack.length === 0) {
154+
this.#commandStack.push(command)
133155
}
134-
135-
await this.#sessionCapturer.injectScript(getBrowserObject(this.#browser))
136156
}
137157

158+
138159
afterCommand(command: keyof WebDriverCommands, args: any[], result: any, error?: Error) {
139-
if (this.#browser) {
140-
return this.#sessionCapturer.afterCommand(this.#browser, command, args, result, error)
160+
/* THE FIX: Ensure that the command is captured only if it matches the last command in the stack.
161+
* This prevents capturing commands that are not top-level user commands.
162+
*/
163+
if (this.#commandStack[this.#commandStack.length - 1] === command) {
164+
this.#commandStack.pop()
165+
if (this.#browser) {
166+
return this.#sessionCapturer.afterCommand(this.#browser, command, args, result, error)
167+
}
141168
}
142169
}
143170

@@ -146,7 +173,7 @@ export default class DevToolsHookService implements Services.ServiceInstance {
146173
* we can use it to write all trace information to a file
147174
*/
148175
async after () {
149-
if (!this.#browser || this.#sessionCapturer.isReportingUpstream) {
176+
if (!this.#browser) {
150177
return
151178
}
152179
const outputDir = this.#browser.options.outputDir || process.cwd()
@@ -169,6 +196,6 @@ export default class DevToolsHookService implements Services.ServiceInstance {
169196
const traceFilePath = path.join(outputDir, `wdio-trace-${this.#browser.sessionId}.json`)
170197
await fs.writeFile(traceFilePath, JSON.stringify(traceLog))
171198
log.info(`DevTools trace saved to ${traceFilePath}`)
172-
await browser.pause(1000 * 60 * 5)
199+
await this.#browser.pause(1000 * 60 * 5)
173200
}
174201
}

packages/service/src/launcher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class DevToolsAppLauncher {
2929

3030
this.#updateCapabilities(caps, { port })
3131
this.#browser = await remote({
32+
automationProtocol: 'devtools',
3233
capabilities: {
3334
...DEFAULT_LAUNCH_CAPS,
3435
...this.#options.devtoolsCapabilities

0 commit comments

Comments
 (0)