Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
24b5db0
feat: prototype reusable trace webview
hi-ogawa-agent Sep 7, 2026
1a4c3b5
refactor: simplify trace webview navigation
hi-ogawa-agent Sep 7, 2026
a6eacc5
fix: preserve trace webview origin and allow Google Fonts
hi-ogawa Sep 7, 2026
eabc7ac
fix: select available traces when refreshing the report
hi-ogawa Sep 7, 2026
34d0b88
fix: preserve trace step when refreshing the same test
hi-ogawa Sep 7, 2026
e254f54
docs: simplify trace view description
hi-ogawa Sep 7, 2026
a15aeab
docs: remove trace webview prototype note
hi-ogawa Sep 7, 2026
0fc2fcf
fix: remove trace webview padding
hi-ogawa Sep 8, 2026
623ed28
fix: restore light trace background
hi-ogawa Sep 8, 2026
222d9b8
feat: use focused trace layout
hi-ogawa Sep 8, 2026
d591b25
docs: update trace view screenshot
hi-ogawa Sep 8, 2026
6db2185
fix: preserve trace attempt on refresh
hi-ogawa Sep 8, 2026
5c69553
refactor: extract trace webview HTML transform
hi-ogawa Sep 8, 2026
59a7b98
refactor: build trace selection hash directly
hi-ogawa Sep 8, 2026
a55d16f
refactor: remove trace metadata cache busting
hi-ogawa Sep 8, 2026
2dd25a7
docs: clarify trace metadata URL comment
hi-ogawa Sep 8, 2026
1139be4
docs: clarify trace asset URL comment
hi-ogawa Sep 8, 2026
b0a0bfb
refactor: extract trace webview CSP string
hi-ogawa Sep 8, 2026
2df2f54
refactor: inline webview CSP source
hi-ogawa Sep 8, 2026
8f174b4
refactor: extract trace webview CSS string
hi-ogawa Sep 8, 2026
5f7b3c9
refactor: extract trace webview bootstrap
hi-ogawa Sep 8, 2026
dbda51a
nit
hi-ogawa Sep 8, 2026
1fb0c97
nit
hi-ogawa Sep 8, 2026
0d7c554
refactor: reload webview when opening traces
hi-ogawa Sep 8, 2026
a7822b4
refactor: name debounced trace refresh
hi-ogawa Sep 8, 2026
261b501
refactor: simplify trace hash serialization
hi-ogawa Sep 8, 2026
e0eab85
refactor: clarify trace view URL hash names
hi-ogawa Sep 8, 2026
8b091ad
refactor: inline trace URL hash serialization
hi-ogawa Sep 8, 2026
2474e61
nit
hi-ogawa Sep 8, 2026
a66575f
refactor: separate trace reconciliation from refresh
hi-ogawa Sep 8, 2026
0daf49a
refactor: reconcile trace target on results update
hi-ogawa Sep 8, 2026
4b4b190
refactor: type trace selection message
hi-ogawa Sep 8, 2026
68cf15e
refactor: type trace message receiver
hi-ogawa Sep 8, 2026
025c623
refactor: remove trace message payload validation
hi-ogawa Sep 8, 2026
ac56de9
nit
hi-ogawa Sep 8, 2026
863c87d
refactor: align trace selection message fields
hi-ogawa Sep 8, 2026
ad88546
refactor: follow latest trace report
hi-ogawa Sep 8, 2026
97b437e
fix: refresh trace reports from filesystem events
hi-ogawa Sep 8, 2026
c3a305b
refactor: group active trace view state
hi-ogawa Sep 8, 2026
30ad2d8
refactor: simplify trace target updates
hi-ogawa Sep 8, 2026
5304485
refactor: clarify trace view state naming
hi-ogawa Sep 8, 2026
8fa8489
refactor: remove redundant trace target assignment
hi-ogawa Sep 8, 2026
bef6464
fix: restore latest trace target fallback
hi-ogawa Sep 8, 2026
c52125f
fix: select trace from latest run across processes
hi-ogawa Sep 8, 2026
0fad40b
docs: explain trace view lifecycle steps
hi-ogawa Sep 8, 2026
52ee818
docs: clarify trace open action comments
hi-ogawa Sep 8, 2026
2ae1a21
fix: keep selected trace when still available
hi-ogawa Sep 8, 2026
17dbae5
nit
hi-ogawa Sep 8, 2026
2e45799
refactor: use revision for stale trace loads
hi-ogawa Sep 8, 2026
ae91639
refactor: let report UI handle missing traces
hi-ogawa Sep 8, 2026
2914a85
refactor: narrow trace report read error handling
hi-ogawa Sep 8, 2026
6958703
nit
hi-ogawa Sep 8, 2026
51a8151
nit
hi-ogawa Sep 8, 2026
0dcd0e2
refactor: simplify trace message revision guard
hi-ogawa Sep 8, 2026
a42cf09
docs: update trace view screenshot
hi-ogawa Sep 8, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export default defineConfig({
})
```

After running a browser test through the extension, right-click the test in the Testing view or its editor gutter icon and select **Open Trace View**. The extension opens the trace view of a selected test from the generated HTML report inside Visual Studio Code's Integrated Browser.
After running a browser test through the extension, right-click the test in the Testing view or its editor gutter icon and select **Open Trace View**. The extension opens the trace view of the selected test from the generated HTML report in an editor tab.

![Vitest Trace View open in Visual Studio Code](./img/trace-view.png)

Expand Down
Binary file modified img/trace-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ class VitestExtension {
this.testController.dispose()
this.schemaProvider.dispose()
this.importsBreakdownProvider.dispose()
this.traceViewManager.dispose()
this.runProfiles.forEach((p) => p.dispose())
this.runProfiles.clear()
this.disposables.forEach((d) => d.dispose())
Expand Down
256 changes: 243 additions & 13 deletions packages/extension/src/traceView.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RunnerTask, RunnerTestFile } from 'vitest'
import type { TestTree } from './testTree'
import { randomBytes } from 'node:crypto'
import * as vscode from 'vscode'

interface TraceViewTarget {
Expand All @@ -9,22 +10,71 @@ interface TraceViewTarget {
testId: string
}

type TraceSelectionMessage = {
type: 'traceSelection'
revision: number
testId: string | null
traceAttempt: string | null
traceStep: number
}

interface TraceViewState {
panel: vscode.WebviewPanel
target: TraceViewTarget
watcher: vscode.FileSystemWatcher
traceAttempt?: string
traceStep: number
refreshTimer?: ReturnType<typeof setTimeout>
}

export class TraceViewManager {
private targets = new Map<vscode.TestItem, TraceViewTarget>()
private viewState?: TraceViewState
// Invalidate older documents on refresh, target changes, and panel disposal.
private revision = 0

dispose() {
this.viewState?.panel.dispose()
}

clear() {
this.targets.clear()
void this.updateContext()
}

async update(apiId: string, reportPath: string, files: RunnerTestFile[], tree: TestTree) {
// Remove Open Trace View actions from the previous run of this process.
for (const [item, target] of this.targets) {
if (target.apiId === apiId) {
this.targets.delete(item)
}
}

for (const target of findTraceViewTargets(apiId, reportPath, files)) {
// Keep the selected test when available, otherwise follow the first traced test.
// HTML report file writes trigger the reload.
const targets = findTraceViewTargets(apiId, reportPath, files)
const viewState = this.viewState
const target =
targets.find(
(target) =>
target.apiId === viewState?.target.apiId && target.testId === viewState.target.testId,
) ?? targets[0]
if (viewState && target) {
if (viewState.target.reportPath !== target.reportPath) {
clearTimeout(viewState.refreshTimer)
viewState.watcher.dispose()
viewState.watcher = this.watchReport(target.reportPath)
}
if (viewState.target.apiId !== target.apiId || viewState.target.testId !== target.testId) {
this.revision++
viewState.target = target
viewState.traceAttempt = undefined
viewState.traceStep = 0
}
}

// Register Open Trace View actions for the latest results.
for (const target of targets) {
const item = tree.getTestItemByTaskId(target.testId)
if (item) {
this.targets.set(item, target)
Expand All @@ -43,7 +93,9 @@ export class TraceViewManager {
}

async open(testItem: vscode.TestItem) {
const target = this.targets.get(testItem)!
// Resolve the requested test and check that its report exists.
const target = this.targets.get(testItem)
if (!target) return

const reportUri = vscode.Uri.file(target.reportPath)
try {
Expand All @@ -55,16 +107,105 @@ export class TraceViewManager {
return
}

const commands = await vscode.commands.getCommands(true)
const url = createTraceViewUrl(target)
// The Integrated Browser command is internal; follow Simple Browser's feature detection before using it.
// https://github.com/microsoft/vscode/blob/008427a901bf4aa79b47f175ccc8da1731750f78/extensions/simple-browser/src/extension.ts#L15-L35
if (commands.includes('workbench.action.browser.open')) {
await vscode.commands.executeCommand('workbench.action.browser.open', url)
// Create the panel and its listeners on the first open.
let viewState = this.viewState
if (!viewState) {
const panel = vscode.window.createWebviewPanel(
'vitest.traceView',
'Vitest Trace View',
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{ enableScripts: true, retainContextWhenHidden: true },
)
viewState = {
panel,
target,
watcher: this.watchReport(target.reportPath),
traceStep: 0,
}
this.viewState = viewState
// Remember attempt and step changes for the next report reload.
panel.webview.onDidReceiveMessage((message: TraceSelectionMessage) => {
const viewState = this.viewState
if (viewState && message.type === 'traceSelection' && message.revision === this.revision) {
viewState.traceAttempt = message.traceAttempt ?? undefined
viewState.traceStep = message.traceStep
}
})
// Release the watcher and pending reload when the panel closes.
panel.onDidDispose(() => {
const viewState = this.viewState
if (viewState?.panel !== panel) return
viewState.watcher.dispose()
clearTimeout(viewState.refreshTimer)
this.viewState = undefined
this.revision++
})
} else {
// TODO: Support a single-file fallback because external browsers may block file:// metadata requests.
await vscode.env.openExternal(vscode.Uri.parse(url))
// Reuse the panel for the requested test with its initial attempt and step.
clearTimeout(viewState.refreshTimer)
viewState.watcher.dispose()
viewState.watcher = this.watchReport(target.reportPath)
viewState.target = target
viewState.traceAttempt = undefined
viewState.traceStep = 0
}
// Explicit opens load immediately. Later report changes reload through the watcher.
viewState.panel.reveal(undefined, true)
await this.refresh()
}

private watchReport(reportPath: string) {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(
vscode.Uri.joinPath(vscode.Uri.file(reportPath), '..'),
'{index.html,ui/html.meta.json.gz}',
),
)
const debouncedRefresh = () => {
const viewState = this.viewState
if (viewState?.watcher !== watcher) return
clearTimeout(viewState.refreshTimer)
viewState.refreshTimer = setTimeout(() => void this.refresh(), 150)
}
watcher.onDidChange(debouncedRefresh)
watcher.onDidCreate(debouncedRefresh)
return watcher
}

private async refresh() {
// Capture the active view and invalidate older loads.
const viewState = this.viewState
if (!viewState) return
const { panel, target } = viewState
const revision = ++this.revision

// Restore the current test, attempt, and step in the new document.
const traceViewUrlHash = createTraceViewUrlHash(
target,
viewState.traceStep,
viewState.traceAttempt,
)
const reportUri = vscode.Uri.file(target.reportPath)
// Read the generated report and discard it if the view changed while loading.
let reportHtml: Uint8Array
try {
reportHtml = await vscode.workspace.fs.readFile(reportUri)
} catch (error) {
await vscode.window.showWarningMessage(`Failed to load Vitest trace report: ${String(error)}`)
return
}
if (revision !== this.revision) return

// Adapt report resources and bootstrap code for the webview.
const directory = vscode.Uri.joinPath(reportUri, '..')
panel.webview.options = { enableScripts: true, localResourceRoots: [directory] }
panel.webview.html = transformTraceViewHtml(
Buffer.from(reportHtml).toString('utf8'),
panel.webview,
directory,
traceViewUrlHash,
revision,
)
}
}

Expand Down Expand Up @@ -94,13 +235,102 @@ function findTraceViewTargets(
return targets
}

function createTraceViewUrl(target: TraceViewTarget) {
function createTraceViewUrlHash(target: TraceViewTarget, traceStep = 0, traceAttempt?: string) {
// https://github.com/vitest-dev/vitest/blob/decfeb61c71a93372f84b6d43893df86a1756308/packages/ui/client/composables/params.ts#L3-L24
const params = new URLSearchParams({
file: target.fileId,
layout: 'trace',
view: 'editor',
test: target.testId,
traceStep: '0',
traceStep: String(traceStep),
})
return `${vscode.Uri.file(target.reportPath).toString(true)}#/?${params}`
if (traceAttempt) {
params.set('traceAttempt', traceAttempt)
}
return `/?${params}`
}

function transformTraceViewHtml(
html: string,
webview: vscode.Webview,
directory: vscode.Uri,
traceViewUrlHash: string,
revision: number,
) {
const base = `${webview.asWebviewUri(directory).toString()}/`
const nonce = randomBytes(16).toString('hex')
const csp = [
`default-src 'none'`,
`script-src ${webview.cspSource} 'nonce-${nonce}'`,
`style-src ${webview.cspSource} https://fonts.googleapis.com 'unsafe-inline'`,
`img-src ${webview.cspSource} data: blob: https:`,
`font-src ${webview.cspSource} data: https:`,
`connect-src ${webview.cspSource}`,
`frame-src 'self' blob: data:;`,
].join('; ')
// Resolve metadata from the report directory instead of the webview URL.
const metadata = webview.asWebviewUri(vscode.Uri.joinPath(directory, 'ui', 'html.meta.json.gz'))
html = html.replace(
/new URL\("\.\/ui\/html\.meta\.json\.gz", window\.location\.href\)/g,
JSON.stringify(metadata.toString()),
)
// Resolve relative asset URLs from the report directory.
html = html.replace(/<(script|link|img|source)\b[^>]*>/gi, (tag) =>
tag.replace(/(\s)(src|href)\s*=\s*(['"])(.*?)\3/gi, (attribute, space, name, quote, value) => {
if (!value || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return attribute
const url = new URL(value.replace(/&amp;/g, '&'), base).href
return `${space}${name}=${quote}${url.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')}${quote}`
}),
)
html = html.replace(/<script\b/g, `<script nonce="${nonce}"`)
html = html.replace(
/<head\b[^>]*>/i,
`$&
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>${TRACE_VIEW_CSS}</style>
<script nonce="${nonce}">
(${initializeTraceView.toString()})(acquireVsCodeApi(), window, ${JSON.stringify(traceViewUrlHash)}, ${revision});
</script>`,
)
return html
}

const TRACE_VIEW_CSS = `
body {
padding: 0;
color: var(--color-text);
}
html:not(.dark) {
background-color: white;
color-scheme: light;
}
`

function initializeTraceView(vscode: any, window: any, traceViewUrlHash: string, revision: number) {
const reportSelection = () => {
const params = new URLSearchParams(window.location.hash.split('?')[1])
const step = params.get('traceStep')
if (step !== null) {
vscode.postMessage({
type: 'traceSelection',
revision,
testId: params.get('test'),
traceAttempt: params.get('traceAttempt'),
traceStep: Number(step),
} satisfies TraceSelectionMessage)
}
}
// Vitest updates URL parameters through History, which does not
// emit hashchange events.
for (const method of ['replaceState', 'pushState']) {
const original = window.history[method]
window.history[method] = function (...args: any[]) {
const result = original.apply(this, args)
reportSelection()
return result
}
}
window.addEventListener('hashchange', reportSelection)
window.addEventListener('popstate', reportSelection)
window.location.hash = traceViewUrlHash
}
Loading