Skip to content
Open
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@
"default": true,
"scope": "resource"
},
"vitest.showSnapshotPreview": {
"markdownDescription": "Preview the stored snapshot when hovering over `.toMatchSnapshot()` and `.toThrowErrorMatchingSnapshot()`. When enabled, go-to-definition also jumps to the snapshot entry in the `.snap` file. Requires TypeScript to be installed in the workspace.",
"type": "boolean",
"default": true,
"scope": "resource"
},
"vitest.forceCancelTimeout": {
"markdownDescription": "When the 'Stop' button is clicked, the extension tries to stop tests gracefully so they don't keep any hanging processes. By default, if tests didn't finish in 1 second, the extension will kill any Vitest process which may keep your test's 'child_process' alive. You can configure the timeout with this option, but consider using [`signal`](https://vitest.dev/guide/test-context#signal) API inside of your tests instead.",
"type": "number",
Expand Down
2 changes: 2 additions & 0 deletions packages/extension/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
const applyDiagnostic = get<boolean>('applyDiagnostic', true)
const ignoreWorkspace = get<boolean>('ignoreWorkspace', false) ?? false
const showInlineConsoleLog = get<boolean>('showInlineConsoleLog', true) ?? true
const showSnapshotPreview = get<boolean>('showSnapshotPreview', true) ?? true
const forceCancelTimeout = get<number>('forceCancelTimeout', 1000) ?? 1000
const runtime = get<'node' | 'deno' | 'auto'>('runtime', 'auto') ?? 'auto'
const watchOnStartup = get<boolean>('watchOnStartup', false) ?? false
Expand Down Expand Up @@ -117,6 +118,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
logLevel,
showImportsDuration: get<boolean>('showImportsDuration', true) ?? true,
showInlineConsoleLog,
showSnapshotPreview,
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import './polyfills'
import { SnapshotEntryTool } from './snapshot/tools'
import { SnapshotDocumentSymbolProvider } from './snapshot/documentSymbolProvider'
import { SnapshotFoldingRangeProvider } from './snapshot/foldingRangeProvider'
import { SnapshotDefinitionProvider } from './snapshot/definitionProvider'
import { SnapshotHoverProvider } from './snapshot/hoverProvider'
import { snapshotTestLanguages } from './snapshot/languages'

export async function activate(context: vscode.ExtensionContext) {
const extension = new VitestExtension(context)
Expand Down Expand Up @@ -557,6 +560,14 @@ class VitestExtension {
{ language: 'vitest-snapshot' },
new SnapshotFoldingRangeProvider(snapshotEntryTool),
),
vscode.languages.registerHoverProvider(
snapshotTestLanguages,
new SnapshotHoverProvider(snapshotEntryTool),
),
vscode.languages.registerDefinitionProvider(
snapshotTestLanguages,
new SnapshotDefinitionProvider(snapshotEntryTool),
),
]

// if the config changes, re-define all test profiles
Expand Down
29 changes: 29 additions & 0 deletions packages/extension/src/snapshot/definitionProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as vscode from 'vscode'
import { getConfig } from '../config'
import { ExternalMatcherPattern } from './matchers'
import { resolveSnapshotAt } from './resolveSnapshotAt'
import type { SnapshotEntryTool } from './tools'

/**
* Go to definition (Ctrl/Cmd+click) on a `toMatchSnapshot` /
* `toThrowErrorMatchingSnapshot` call opens the matching entry in the `.snap`
* file. Falls back to the default definition when the snapshot cannot be resolved.
*/
export class SnapshotDefinitionProvider implements vscode.DefinitionProvider {
constructor(private snapshotEntryTool: SnapshotEntryTool) {}

async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
): Promise<vscode.Location | undefined> {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!getConfig(workspaceFolder).showSnapshotPreview) return undefined
if (!document.getWordRangeAtPosition(position, ExternalMatcherPattern)) return undefined

const resolved = await resolveSnapshotAt(document, position, token, this.snapshotEntryTool)
if (!resolved || token.isCancellationRequested) return undefined

return new vscode.Location(resolved.snapshotDocument.uri, resolved.entry.bodyRange)
}
}
60 changes: 60 additions & 0 deletions packages/extension/src/snapshot/hoverProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as vscode from 'vscode'
import { getConfig } from '../config'
import { ExternalMatcherPattern } from './matchers'
import { resolveSnapshotAt } from './resolveSnapshotAt'
import type { SnapshotEntryTool } from './tools'

const MaxPreviewLength = 8 * 1024

/** Length of the longest run of backticks in `text` (0 when there are none). */
function longestBacktickRun(text: string): number {
let longest = 0
for (const match of text.matchAll(/`+/g)) longest = Math.max(longest, match[0].length)
return longest
}

/** Build a fenced code block whose fence cannot be closed by the content. */
function codeBlock(content: string): string {
const fence = '`'.repeat(Math.max(2, longestBacktickRun(content)) + 1)
return `${fence}text\n${content}\n${fence}`
}

/** Wrap `text` as a Markdown code span that tolerates backticks in the text. */
function inlineCode(text: string): string {
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padding = text.startsWith('`') || text.endsWith('`') ? ' ' : ''
return `${fence}${padding}${text}${padding}${fence}`
}

function previewBody(body: string): string {
if (body.length <= MaxPreviewLength) return body
return `${body.slice(0, MaxPreviewLength)}\n鈥
}

/**
* Show the stored snapshot value when hovering a `toMatchSnapshot` /
* `toThrowErrorMatchingSnapshot` call. Falls back to the default hover when the
* snapshot cannot be resolved.
*/
export class SnapshotHoverProvider implements vscode.HoverProvider {
constructor(private snapshotEntryTool: SnapshotEntryTool) {}

async provideHover(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
): Promise<vscode.Hover | undefined> {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!getConfig(workspaceFolder).showSnapshotPreview) return undefined
if (!document.getWordRangeAtPosition(position, ExternalMatcherPattern)) return undefined

const resolved = await resolveSnapshotAt(document, position, token, this.snapshotEntryTool)
if (!resolved || token.isCancellationRequested) return undefined

const markdown = new vscode.MarkdownString()
markdown.appendMarkdown(`**Vitest snapshot** ${inlineCode(resolved.key)}\n\n`)
markdown.appendMarkdown(codeBlock(previewBody(resolved.entry.body)))
markdown.isTrusted = false
return new vscode.Hover(markdown, resolved.nameRange)
}
}
9 changes: 9 additions & 0 deletions packages/extension/src/snapshot/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type * as vscode from 'vscode'

/** Languages whose test files can contain `toMatchSnapshot` style assertions. */
export const snapshotTestLanguages: vscode.DocumentSelector = [
{ scheme: 'file', language: 'typescript' },
{ scheme: 'file', language: 'typescriptreact' },
{ scheme: 'file', language: 'javascript' },
{ scheme: 'file', language: 'javascriptreact' },
]
25 changes: 25 additions & 0 deletions packages/extension/src/snapshot/loadTypescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createRequire } from 'node:module'
import { dirname } from 'pathe'
import type { Ts } from './types'

const typescriptCache = new Map<string, Ts | undefined>()

/**
* Load the `typescript` package installed in the workspace so the extension does
* not have to bundle the compiler. Returns `undefined` when it is not installed.
*
* Failed lookups are cached too, so a workspace without TypeScript is only probed once.
*/
export function loadWorkspaceTypescript(fromFile: string): Ts | undefined {
const cacheKey = dirname(fromFile)
if (typescriptCache.has(cacheKey)) return typescriptCache.get(cacheKey)

let typescript: Ts | undefined
try {
typescript = createRequire(fromFile)('typescript') as Ts
} catch {
typescript = undefined
}
typescriptCache.set(cacheKey, typescript)
return typescript
}
22 changes: 22 additions & 0 deletions packages/extension/src/snapshot/matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type SnapshotMatcher = 'toMatchSnapshot' | 'toThrowErrorMatchingSnapshot'

/** Matchers that read or write an entry in the external `.snap` file. */
export const ExternalMatchers: ReadonlySet<string> = new Set([
'toMatchSnapshot',
'toThrowErrorMatchingSnapshot',
])

/** Pattern matching the external matcher names, for `getWordRangeAtPosition`. */
export const ExternalMatcherPattern = new RegExp(Array.from(ExternalMatchers).join('|'))

/**
* Matchers that consume Vitest's per-test snapshot counter. Inline and file
* snapshots are not stored in `.snap`, but they still take a counter slot, so
* they shift the index of any later external snapshot in the same test.
*/
export const CounterMatchers: ReadonlySet<string> = new Set([
...ExternalMatchers,
'toMatchInlineSnapshot',
'toThrowErrorMatchingInlineSnapshot',
'toMatchFileSnapshot',
])
105 changes: 105 additions & 0 deletions packages/extension/src/snapshot/parseSnapshotFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Parsing helpers for Vitest/Jest `.snap` files.
*
* Vitest writes snapshots as `exports[`<key>`] = `<value>`;` where backticks,
* backslashes and `${` inside the key/value are escaped. These helpers are
* `vscode`-free so they can be unit tested.
*/

const ValueStart = '= `'

export interface ParsedSnapshotEntry {
/** Snapshot key with Vitest's escaping reversed. */
name: string
/** Serialized snapshot value with Vitest's escaping reversed. */
body: string
/** Offset of `exports[`. */
start: number
/** Offset just past the closing `;`. */
end: number
/** Offset range of the raw key (between the backticks). */
keyStart: number
keyEnd: number
/** Offset range of the raw value (between the backticks). */
bodyStart: number
bodyEnd: number
/** Offset range of the value without Vitest's padding newlines. */
contentStart: number
contentEnd: number
}

/** Reverse Vitest's `printBacktickString` escaping. */
function unescapeSnapshot(value: string): string {
return value.replace(/\\(`|\\|\$\{)/g, '$1')
}

/** Reverse Vitest's `addExtraLineBreaks` padding around multiline snapshots. */
function stripExtraLineBreaks(value: string): string {
return value.length > 2 && value[0] === '\n' && value.endsWith('\n') ? value.slice(1, -1) : value
}

/** Offset of the closing backtick of a backtick string starting at `start`, or -1. */
function findValueEnd(text: string, start: number): number {
let index = start
while (index < text.length) {
const char = text[index]
if (char === '\\') {
index += 2
continue
}
if (char === '`') return index
index += 1
}
return -1
}

/**
* Parse every `exports[`key`] = `value`;` block out of a `.snap` file.
* Returns character offsets so callers can map them back to editor ranges.
*/
export function parseSnapshotEntries(text: string): ParsedSnapshotEntry[] {
const entries: ParsedSnapshotEntry[] = []
const exportRegex = /^exports\[`((?:\\.|[^`\\])*)`\]/gm
let match: RegExpExecArray | null
while ((match = exportRegex.exec(text))) {
const start = match.index
const rawKey = match[1]
const keyStart = start + 'exports[`'.length
const keyEnd = keyStart + rawKey.length

const valueStart = text.indexOf(ValueStart, keyEnd)
if (valueStart === -1) continue
const bodyStart = valueStart + ValueStart.length

const bodyEnd = findValueEnd(text, bodyStart)
if (bodyEnd === -1) continue
const end = text[bodyEnd + 1] === ';' ? bodyEnd + 2 : bodyEnd + 1

const rawBody = text.slice(bodyStart, bodyEnd)
const contentBody = stripExtraLineBreaks(rawBody)
const hasPadding = contentBody.length !== rawBody.length

entries.push({
name: unescapeSnapshot(rawKey),
body: unescapeSnapshot(contentBody),
start,
end,
keyStart,
keyEnd,
bodyStart,
bodyEnd,
contentStart: hasPadding ? bodyStart + 1 : bodyStart,
contentEnd: hasPadding ? bodyEnd - 1 : bodyEnd,
})

exportRegex.lastIndex = end
}
return entries
}

export function findSnapshotEntry(
entries: ParsedSnapshotEntry[],
key: string,
): ParsedSnapshotEntry | undefined {
return entries.find((entry) => entry.name === key)
}
86 changes: 86 additions & 0 deletions packages/extension/src/snapshot/resolveSnapshotAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as vscode from 'vscode'
import { loadWorkspaceTypescript } from './loadTypescript'
import { resolveSnapshotCall } from './resolveSnapshotCall'
import { resolveSnapshotPath } from './resolveSnapshotPath'
import type { SnapshotEntry, SnapshotEntryTool } from './tools'
import type { Ts } from './types'

export interface ResolvedSnapshot {
key: string
matcher: string
/** Range of the matcher identifier in the test file. */
nameRange: vscode.Range
snapshotDocument: vscode.TextDocument
entry: SnapshotEntry
}

function scriptKindFor(ts: Ts, languageId: string) {
switch (languageId) {
case 'typescriptreact':
return ts.ScriptKind.TSX
case 'javascriptreact':
return ts.ScriptKind.JSX
case 'javascript':
return ts.ScriptKind.JS
default:
return ts.ScriptKind.TS
}
}

/**
* Resolve the snapshot referenced by the assertion at `position`: load the
* workspace TypeScript, compute the Vitest key, open the `.snap` file and find
* the matching entry. Returns `undefined` on any unsupported/missing case.
*/
export async function resolveSnapshotAt(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
snapshotEntryTool: SnapshotEntryTool,
): Promise<ResolvedSnapshot | undefined> {
if (document.uri.scheme !== 'file') return undefined

const ts = loadWorkspaceTypescript(document.uri.fsPath)
if (!ts) return undefined

const sourceFile = ts.createSourceFile(
document.fileName,
document.getText(),
ts.ScriptTarget.Latest,
true,
scriptKindFor(ts, document.languageId),
)
const call = resolveSnapshotCall(ts, sourceFile, document.offsetAt(position))
if (!call) return undefined

const snapshotPath = resolveSnapshotPath(document.uri.fsPath)
let snapshotDocument: vscode.TextDocument
try {
snapshotDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(snapshotPath))
} catch {
return undefined
}
if (token.isCancellationRequested) return undefined

snapshotEntryTool.process(
snapshotDocument,
snapshotDocument.uri.toString(),
snapshotDocument.version,
token,
)
if (token.isCancellationRequested) return undefined

const entry = snapshotEntryTool.snapshotEntries.find((candidate) => candidate.name === call.key)
if (!entry) return undefined

return {
key: call.key,
matcher: call.matcher,
nameRange: new vscode.Range(
document.positionAt(call.nameStart),
document.positionAt(call.nameEnd),
),
snapshotDocument,
entry,
}
}
Loading