Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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