Skip to content

Commit 1eb1a65

Browse files
committed
TestRunner initial commit
1 parent 0066fba commit 1eb1a65

File tree

6 files changed

+455
-2
lines changed

6 files changed

+455
-2
lines changed

packages/app/src/components/sidebar/explorer.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { html, css, nothing, type TemplateResult } from 'lit'
33
import { customElement } from 'lit/decorators.js'
44
import { consume } from '@lit/context'
55
import type { TestStats, SuiteStats } from '@wdio/reporter'
6+
import type { Metadata } from '@wdio/devtools-service/types'
67
import { repeat } from 'lit/directives/repeat.js'
78
import { TestState } from './test-suite.js'
8-
import { suiteContext } from '../../controller/DataManager.js'
9+
import { suiteContext, metadataContext } from '../../controller/DataManager.js'
910

1011
import '~icons/mdi/play.js'
1112
import '~icons/mdi/stop.js'
@@ -16,6 +17,7 @@ import '~icons/mdi/expand-all.js'
1617
import './test-suite.js'
1718
import { CollapseableEntry } from './collapseableEntry.js'
1819
import type { DevtoolsSidebarFilter } from './filter.js'
20+
import type { TestRunDetail } from './test-suite.js'
1921

2022
const EXPLORER = 'wdio-devtools-sidebar-explorer'
2123

@@ -25,11 +27,17 @@ interface TestEntry {
2527
label: string
2628
callSource?: string
2729
children: TestEntry[]
30+
type: 'suite' | 'test'
31+
specFile?: string
32+
fullTitle?: string
2833
}
2934

3035
@customElement(EXPLORER)
3136
export class DevtoolsSidebarExplorer extends CollapseableEntry {
3237
#testFilter: DevtoolsSidebarFilter | undefined
38+
#filterListener = this.#filterTests.bind(this)
39+
#runListener = this.#handleTestRun.bind(this)
40+
#stopListener = this.#handleTestStop.bind(this)
3341

3442
static styles = [
3543
...Element.styles,
@@ -58,21 +66,139 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
5866
@consume({ context: suiteContext, subscribe: true })
5967
suites: Record<string, SuiteStats>[] | undefined = undefined
6068

69+
@consume({ context: metadataContext, subscribe: true })
70+
metadata: Metadata | undefined = undefined
71+
6172
connectedCallback(): void {
6273
super.connectedCallback()
63-
window.addEventListener('app-test-filter', this.#filterTests.bind(this))
74+
window.addEventListener('app-test-filter', this.#filterListener)
75+
this.addEventListener('app-test-run', this.#runListener as EventListener)
76+
this.addEventListener('app-test-stop', this.#stopListener as EventListener)
77+
}
78+
79+
disconnectedCallback(): void {
80+
super.disconnectedCallback()
81+
window.removeEventListener('app-test-filter', this.#filterListener)
82+
this.removeEventListener('app-test-run', this.#runListener as EventListener)
83+
this.removeEventListener(
84+
'app-test-stop',
85+
this.#stopListener as EventListener
86+
)
6487
}
6588

6689
#filterTests({ detail }: { detail: DevtoolsSidebarFilter }) {
6790
this.#testFilter = detail
6891
this.requestUpdate()
6992
}
7093

94+
async #handleTestRun(event: Event) {
95+
console.log('handleTestRun', event)
96+
event.stopPropagation()
97+
const detail = (event as CustomEvent<TestRunDetail>).detail
98+
await this.#postToBackend('/api/tests/run', {
99+
...detail,
100+
runAll: detail.uid === '*',
101+
framework: this.#getFramework(),
102+
specFile: detail.specFile || this.#deriveSpecFile(detail),
103+
configFile: this.#getConfigPath()
104+
})
105+
}
106+
107+
async #handleTestStop(event: Event) {
108+
event.stopPropagation()
109+
const detail = (event as CustomEvent<TestRunDetail>).detail
110+
await this.#postToBackend('/api/tests/stop', { ...detail })
111+
}
112+
113+
async #postToBackend(path: string, body: Record<string, unknown>) {
114+
try {
115+
const response = await fetch(path, {
116+
method: 'POST',
117+
headers: {
118+
'content-type': 'application/json'
119+
},
120+
body: JSON.stringify(body)
121+
})
122+
if (!response.ok) {
123+
const errorText = await response.text()
124+
throw new Error(errorText || 'Unknown error')
125+
}
126+
} catch (error) {
127+
console.error('Failed to communicate with backend', error)
128+
window.dispatchEvent(
129+
new CustomEvent('app-logs', {
130+
detail: `Test runner error: ${(error as Error).message}`
131+
})
132+
)
133+
}
134+
}
135+
136+
#deriveSpecFile(detail: TestRunDetail) {
137+
if (detail.specFile) {
138+
return detail.specFile
139+
}
140+
const source = detail.callSource
141+
if (source?.startsWith('file://')) {
142+
try {
143+
return new URL(source).pathname
144+
} catch {
145+
return source
146+
}
147+
}
148+
if (source) {
149+
const match = source.match(/^(.*?):\d+:\d+$/)
150+
if (match?.[1]) {
151+
return match[1]
152+
}
153+
return source
154+
}
155+
156+
return undefined
157+
}
158+
159+
#runAllSuites() {
160+
console.log('runAllSuites')
161+
void this.#postToBackend('/api/tests/run', {
162+
uid: '*',
163+
entryType: 'suite',
164+
runAll: true,
165+
framework: this.#getFramework(),
166+
configFile: this.#getConfigPath()
167+
})
168+
}
169+
170+
#stopActiveRun() {
171+
void this.#postToBackend('/api/tests/stop', {
172+
uid: '*'
173+
})
174+
}
175+
176+
#getFramework(): string | undefined {
177+
const options = this.metadata?.options as { framework?: string } | undefined
178+
return options?.framework
179+
}
180+
181+
#getConfigPath(): string | undefined {
182+
const options = this.metadata?.options as
183+
| {
184+
configFile?: string
185+
configFilePath?: string
186+
}
187+
| undefined
188+
console.log('getConfigPath', options?.configFilePath, options?.configFile)
189+
return options?.configFilePath || options?.configFile
190+
}
191+
71192
#renderEntry(entry: TestEntry): TemplateResult {
72193
return html`
73194
<wdio-test-entry
195+
uid="${entry.uid}"
74196
state="${entry.state as any}"
75197
call-source="${entry.callSource || ''}"
198+
entry-type="${entry.type}"
199+
spec-file="${entry.specFile || ''}"
200+
full-title="${entry.fullTitle || ''}"
201+
label-text="${entry.label}"
76202
>
77203
<label slot="label">${entry.label}</label>
78204
${entry.children && entry.children.length
@@ -120,12 +246,15 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
120246
return {
121247
uid: entry.uid,
122248
label: entry.title,
249+
type: 'suite',
123250
state: entry.tests.some((t) => !t.end)
124251
? TestState.RUNNING
125252
: entry.tests.find((t) => t.state === 'failed')
126253
? TestState.FAILED
127254
: TestState.PASSED,
128255
callSource: (entry as any).callSource,
256+
specFile: (entry as any).file,
257+
fullTitle: entry.title,
129258
children: Object.values(entries)
130259
.map(this.#getTestEntry.bind(this))
131260
.filter(this.#filterEntry.bind(this))
@@ -134,12 +263,15 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
134263
return {
135264
uid: entry.uid,
136265
label: entry.title,
266+
type: 'test',
137267
state: !entry.end
138268
? TestState.RUNNING
139269
: entry.state === 'failed'
140270
? TestState.FAILED
141271
: TestState.PASSED,
142272
callSource: (entry as any).callSource,
273+
specFile: (entry as any).file,
274+
fullTitle: (entry as any).fullTitle || entry.title,
143275
children: []
144276
}
145277
}
@@ -171,11 +303,13 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
171303
<nav class="flex ml-auto">
172304
<button
173305
class="p-1 rounded hover:bg-toolbarHoverBackground text-sm group"
306+
@click="${() => this.#runAllSuites()}"
174307
>
175308
<icon-mdi-play class="group-hover:text-chartsGreen"></icon-mdi-play>
176309
</button>
177310
<button
178311
class="p-1 rounded hover:bg-toolbarHoverBackground text-sm group"
312+
@click="${() => this.#stopActiveRun()}"
179313
>
180314
<icon-mdi-stop class="group-hover:text-chartsRed"></icon-mdi-stop>
181315
</button>

packages/app/src/components/sidebar/test-suite.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ import '~icons/mdi/check.js'
1717
import '~icons/mdi/checkbox-blank-circle-outline.js'
1818

1919
const TEST_SUITE = 'wdio-test-suite'
20+
21+
export interface TestRunDetail {
22+
uid: string
23+
entryType: 'suite' | 'test'
24+
specFile?: string
25+
fullTitle?: string
26+
label?: string
27+
callSource?: string
28+
configFile?: string
29+
}
30+
2031
@customElement(TEST_SUITE)
2132
export class ExplorerTestSuite extends Element {
2233
static styles = [
@@ -48,12 +59,27 @@ export class ExplorerTestEntry extends CollapseableEntry {
4859
@property({ attribute: 'is-collapsed' })
4960
isCollapsed = 'false'
5061

62+
@property({ type: String })
63+
uid?: string
64+
5165
@property({ type: String })
5266
state?: TestState
5367

5468
@property({ type: String, attribute: 'call-source' })
5569
callSource?: string
5670

71+
@property({ type: String, attribute: 'entry-type' })
72+
entryType: 'suite' | 'test' = 'suite'
73+
74+
@property({ type: String, attribute: 'spec-file' })
75+
specFile?: string
76+
77+
@property({ type: String, attribute: 'full-title' })
78+
fullTitle?: string
79+
80+
@property({ type: String, attribute: 'label-text' })
81+
labelText?: string
82+
5783
static styles = [
5884
...Element.styles,
5985
css`
@@ -93,6 +119,51 @@ export class ExplorerTestEntry extends CollapseableEntry {
93119
)
94120
}
95121

122+
#runEntry(event: Event) {
123+
console.log('runEntry', this.uid)
124+
event.stopPropagation()
125+
if (!this.uid) {
126+
return
127+
}
128+
const detail: TestRunDetail = {
129+
uid: this.uid,
130+
entryType: this.entryType,
131+
specFile: this.specFile,
132+
fullTitle: this.fullTitle,
133+
label: this.labelText,
134+
callSource: this.callSource
135+
}
136+
this.dispatchEvent(
137+
new CustomEvent<TestRunDetail>('app-test-run', {
138+
detail,
139+
bubbles: true,
140+
composed: true
141+
})
142+
)
143+
}
144+
145+
#stopEntry(event: Event) {
146+
event.stopPropagation()
147+
if (!this.uid) {
148+
return
149+
}
150+
const detail: TestRunDetail = {
151+
uid: this.uid,
152+
entryType: this.entryType,
153+
specFile: this.specFile,
154+
fullTitle: this.fullTitle,
155+
label: this.labelText,
156+
callSource: this.callSource
157+
}
158+
this.dispatchEvent(
159+
new CustomEvent<TestRunDetail>('app-test-stop', {
160+
detail,
161+
bubbles: true,
162+
composed: true
163+
})
164+
)
165+
}
166+
96167
get hasPassed() {
97168
return this.state === TestState.PASSED
98169
}
@@ -164,6 +235,7 @@ export class ExplorerTestEntry extends CollapseableEntry {
164235
? html`
165236
<button
166237
class="p-1 rounded hover:bg-toolbarHoverBackground my-1 group/button"
238+
@click="${(event: Event) => this.#runEntry(event)}"
167239
>
168240
<icon-mdi-play
169241
class="group-hover/button:text-chartsGreen"
@@ -173,6 +245,7 @@ export class ExplorerTestEntry extends CollapseableEntry {
173245
: html`
174246
<button
175247
class="p-1 rounded hover:bg-toolbarHoverBackground my-1 group/button"
248+
@click="${(event: Event) => this.#stopEntry(event)}"
176249
>
177250
<icon-mdi-stop
178251
class="group-hover/button:text-chartsRed"

packages/app/src/vite-env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ interface GlobalEventHandlersEventMap {
1414
'app-test-filter': CustomEvent<
1515
import('./components/sidebar/filter').DevtoolsSidebarFilter
1616
>
17+
'app-test-run': CustomEvent<
18+
import('./components/sidebar/test-suite').TestRunDetail
19+
>
20+
'app-test-stop': CustomEvent<
21+
import('./components/sidebar/test-suite').TestRunDetail
22+
>
1723
'app-logs': CustomEvent<string>
1824
'load-trace': CustomEvent<TraceLog>
1925
'show-command': CustomEvent<CommandEventProps>

packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@fastify/static": "^8.2.0",
2222
"@fastify/websocket": "^11.2.0",
23+
"@wdio/cli": "9.18.0",
2324
"@wdio/devtools-app": "workspace:^",
2425
"@wdio/logger": "9.18.0",
2526
"fastify": "^5.5.0",

packages/backend/src/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { WebSocket } from 'ws'
99

1010
import { getDevtoolsApp } from './utils.js'
1111
import { DEFAULT_PORT } from './constants.js'
12+
import { testRunner, type RunnerRequestBody } from './runner.js'
1213

1314
let server: FastifyInstance | undefined
1415

@@ -31,6 +32,29 @@ export async function start(opts: DevtoolsBackendOptions = {}) {
3132
root: appPath
3233
})
3334

35+
server.post(
36+
'/api/tests/run',
37+
async (request: FastifyRequest<{ Body: RunnerRequestBody }>, reply) => {
38+
console.log('request', request.body)
39+
const body = request.body
40+
if (!body?.uid || !body.entryType) {
41+
return reply.code(400).send({ error: 'Invalid run payload' })
42+
}
43+
try {
44+
await testRunner.run(body)
45+
return reply.send({ ok: true })
46+
} catch (error) {
47+
log.error(`Failed to start test run: ${(error as Error).message}`)
48+
return reply.code(500).send({ error: (error as Error).message })
49+
}
50+
}
51+
)
52+
53+
server.post('/api/tests/stop', async (_request, reply) => {
54+
testRunner.stop()
55+
reply.send({ ok: true })
56+
})
57+
3458
server.get(
3559
'/client',
3660
{ websocket: true },

0 commit comments

Comments
 (0)