Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions packages/app/src/components/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class DevtoolsSidebar extends Element {
border-right: 1px solid var(--vscode-panel-border) !important;
display: flex;
flex-direction: column;
height: 100%;
}
`
]
Expand Down
17 changes: 15 additions & 2 deletions packages/app/src/components/sidebar/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
}

header {
Expand Down Expand Up @@ -120,7 +121,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
runAll: detail.uid === '*',
framework: this.#getFramework(),
specFile: detail.specFile || this.#deriveSpecFile(detail),
configFile: this.#getConfigPath()
configFile: this.#getConfigPath(),
rerunCommand: this.#getRerunCommand(),
launchCommand: this.#getLaunchCommand()
}
await this.#postToBackend('/api/tests/run', payload)
}
Expand Down Expand Up @@ -199,7 +202,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
entryType: 'suite',
runAll: true,
framework: this.#getFramework(),
configFile: this.#getConfigPath()
configFile: this.#getConfigPath(),
rerunCommand: this.#getRerunCommand(),
launchCommand: this.#getLaunchCommand()
})
}

Expand Down Expand Up @@ -277,6 +282,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
return options?.configFilePath || options?.configFile
}

#getRerunCommand(): string | undefined {
return this.#getRunnerOptions()?.rerunCommand
}

#getLaunchCommand(): string | undefined {
return this.#getRunnerOptions()?.launchCommand
}

#renderEntry(entry: TestEntry): TemplateResult {
return html`
<wdio-test-entry
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/sidebar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface RunnerOptions {
configFile?: string
configFilePath?: string
runCapabilities?: Partial<RunCapabilities>
rerunCommand?: string
launchCommand?: string
}

export interface TestRunDetail {
Expand Down
48 changes: 40 additions & 8 deletions packages/app/src/controller/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,15 @@ export class DataManagerController implements ReactiveController {
}

if (scope === 'clearExecutionData') {
const { uid, entryType } =
const { uid, entryType, clearSuiteTree } =
data as SocketMessage<'clearExecutionData'>['data']
this.clearExecutionData(uid, entryType)
if (clearSuiteTree) {
this.suitesContextProvider.setValue([])
this.#activeRerunTestUid = undefined
rerunState.activeRerunSuiteUid = undefined
this.#lastSeenRunTimestamp = 0
}
this.#host.requestUpdate()
return
}
Expand Down Expand Up @@ -542,14 +548,22 @@ export class DataManagerController implements ReactiveController {

#handleReplaceCommand(oldTimestamp: number, newCommand: CommandLog) {
const current = this.commandsContextProvider.value || []
// Find the last entry with the matching timestamp (most recent retry)
const idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp)
// Prefer stable `id` — chained selenium calls share a millisecond.
let idx = -1
const newId = (newCommand as CommandLog & { id?: number }).id
if (typeof newId === 'number') {
idx = current.findIndex(
(c) => (c as CommandLog & { id?: number }).id === newId
)
}
if (idx === -1) {
idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp)
}
if (idx !== -1) {
const updated = [...current]
updated[idx] = newCommand
this.commandsContextProvider.setValue(updated)
} else {
// No matching entry found — just append
this.commandsContextProvider.setValue([...current, newCommand])
}
}
Expand All @@ -562,10 +576,28 @@ export class DataManagerController implements ReactiveController {
}

#handleNetworkRequestsUpdate(data: NetworkRequest[]) {
this.networkRequestsContextProvider.setValue([
...(this.networkRequestsContextProvider.value || []),
...data
])
const current = this.networkRequestsContextProvider.value || []
const byId = new Map<string, number>()
current.forEach((r, i) => {
if (r?.id) {
byId.set(r.id, i)
}
})
const next = [...current]
for (const incoming of data) {
if (!incoming?.id) {
next.push(incoming)
continue
}
const existingIdx = byId.get(incoming.id)
if (existingIdx !== undefined) {
next[existingIdx] = incoming
} else {
byId.set(incoming.id, next.length)
next.push(incoming)
}
}
this.networkRequestsContextProvider.setValue(next)
}

#handleMetadataUpdate(data: Metadata) {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/controller/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export interface SocketMessage<
data: T extends keyof TraceLog
? TraceLog[T]
: T extends 'clearExecutionData'
? { uid?: string; entryType?: 'suite' | 'test' }
? {
uid?: string
entryType?: 'suite' | 'test'
clearSuiteTree?: boolean
}
: T extends 'replaceCommand'
? { oldTimestamp: number; command: CommandLog }
: unknown
Expand Down
106 changes: 67 additions & 39 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,39 @@ interface DevtoolsBackendOptions {
const log = logger('@wdio/devtools-backend')
const clients = new Set<WebSocket>()

/**
* Registry mapping sessionId → absolute path of the encoded .webm file.
* Populated when the service sends { scope: 'screencast', data: { sessionId, videoPath } }.
* Queried by GET /api/video/:sessionId.
*/
// Notify the worker when a UI client connects so the plugin can unblock
// Builder.build() instead of finishing the run before the dashboard appears.
let workerSocket: WebSocket | undefined

// sessionId → absolute path of the encoded .webm; queried by /api/video/:sessionId.
const videoRegistry = new Map<string, string>()

// Replay buffer for clients connecting after the worker has already streamed.
// Required for plugins where the dashboard window spawns asynchronously and
// may attach after a fast run has already completed.
const MESSAGE_BUFFER_LIMIT = 10000
const messageBuffer: string[] = []

export function broadcastToClients(message: string) {
messageBuffer.push(message)
if (messageBuffer.length > MESSAGE_BUFFER_LIMIT) {
messageBuffer.shift()
}
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message)
}
})
}

function replayBufferedMessages(socket: WebSocket) {
for (const msg of messageBuffer) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(msg)
}
}
}

function serveVideo(sessionId: string, reply: any) {
const videoPath = videoRegistry.get(sessionId)
if (!videoPath) {
Expand Down Expand Up @@ -113,44 +131,65 @@ export async function start(
'/client',
{ websocket: true },
(socket: WebSocket, _req: FastifyRequest) => {
log.info('client connected')
log.info(
`client connected (replaying ${messageBuffer.length} buffered message(s))`
)
replayBufferedMessages(socket)
clients.add(socket)
socket.on('close', () => clients.delete(socket))

if (workerSocket?.readyState === WebSocket.OPEN) {
workerSocket.send(
JSON.stringify({ scope: 'clientConnected', data: {} })
)
}
}
)

server.get(
'/worker',
{ websocket: true },
(socket: WebSocket, _req: FastifyRequest) => {
// Drop the message buffer for a fresh run (so late dashboards don't
// replay stale state) but NOT for a rerun child — the dashboard's
// mergeSuite/mergeTests dedupe by uid, and the existing tree should
// stay rendered while sibling tests freeze at their last result.
const isRerunChild = testRunner.consumeRerunChildFlag()
if (!isRerunChild) {
messageBuffer.length = 0
}
workerSocket = socket
socket.on('close', () => {
if (workerSocket === socket) {
workerSocket = undefined
}
})
if (clients.size > 0) {
socket.send(JSON.stringify({ scope: 'clientConnected', data: {} }))
}
socket.on('message', (message: Buffer) => {
log.info(
// Use `debug` — at `info` level this feeds the worker's stream
// capture and creates a backend↔capture loop.
log.debug(
`received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}`
)

// Parse message to check if it needs special handling
try {
const parsed = JSON.parse(message.toString())

// Transform clearCommands → clearExecutionData for the UI
if (parsed.scope === 'clearCommands') {
const testUid = parsed.data?.testUid
log.info(`Clearing commands for test: ${testUid || 'all'}`)
const clearMessage = JSON.stringify({
scope: 'clearExecutionData',
data: { uid: testUid }
})
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(clearMessage)
}
})
broadcastToClients(
JSON.stringify({
scope: 'clearExecutionData',
data: { uid: testUid }
})
)
return
}

// Intercept screencast messages: store the absolute videoPath in the
// registry (backend-only), then forward only the sessionId to the UI
// so the UI can request the video via GET /api/video/:sessionId.
// Strip videoPath before forwarding — the UI fetches via /api/video/:sessionId.
if (parsed.scope === 'screencast' && parsed.data?.sessionId) {
const { sessionId, videoPath } = parsed.data
if (videoPath) {
Expand All @@ -159,35 +198,24 @@ export async function start(
`Screencast registered for session ${sessionId}: ${videoPath}`
)
}
// Forward trimmed message (no videoPath) to UI clients
const uiMessage = JSON.stringify({
scope: 'screencast',
data: { sessionId }
})
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(uiMessage)
}
})
broadcastToClients(
JSON.stringify({
scope: 'screencast',
data: { sessionId }
})
)
return
}
} catch {
// Not JSON or parsing failed, forward as-is
}

// Forward all other messages as-is
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message.toString())
}
})
broadcastToClients(message.toString())
})
}
)

// Serve recorded screencast videos. The service sends an absolute videoPath
// which is stored in videoRegistry; the UI only knows the sessionId and
// requests the file through this endpoint.
server.get(
'/api/video/:sessionId',
{
Expand Down
Loading
Loading