diff --git a/package.json b/package.json index 689fce61..3dc9b6ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/extension/src/config.ts b/packages/extension/src/config.ts index 60cf85ef..bbfaca1f 100644 --- a/packages/extension/src/config.ts +++ b/packages/extension/src/config.ts @@ -85,6 +85,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) { const applyDiagnostic = get('applyDiagnostic', true) const ignoreWorkspace = get('ignoreWorkspace', false) ?? false const showInlineConsoleLog = get('showInlineConsoleLog', true) ?? true + const showSnapshotPreview = get('showSnapshotPreview', true) ?? true const forceCancelTimeout = get('forceCancelTimeout', 1000) ?? 1000 const runtime = get<'node' | 'deno' | 'auto'>('runtime', 'auto') ?? 'auto' const watchOnStartup = get('watchOnStartup', false) ?? false @@ -117,6 +118,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) { logLevel, showImportsDuration: get('showImportsDuration', true) ?? true, showInlineConsoleLog, + showSnapshotPreview, } } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 846071e8..b7ae4ab1 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -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) @@ -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 diff --git a/packages/extension/src/snapshot/definitionProvider.ts b/packages/extension/src/snapshot/definitionProvider.ts new file mode 100644 index 00000000..991bf9c2 --- /dev/null +++ b/packages/extension/src/snapshot/definitionProvider.ts @@ -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 { + 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) + } +} diff --git a/packages/extension/src/snapshot/hoverProvider.ts b/packages/extension/src/snapshot/hoverProvider.ts new file mode 100644 index 00000000..815603d2 --- /dev/null +++ b/packages/extension/src/snapshot/hoverProvider.ts @@ -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 { + 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) + } +} diff --git a/packages/extension/src/snapshot/languages.ts b/packages/extension/src/snapshot/languages.ts new file mode 100644 index 00000000..696d0353 --- /dev/null +++ b/packages/extension/src/snapshot/languages.ts @@ -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' }, +] diff --git a/packages/extension/src/snapshot/loadTypescript.ts b/packages/extension/src/snapshot/loadTypescript.ts new file mode 100644 index 00000000..29be21c0 --- /dev/null +++ b/packages/extension/src/snapshot/loadTypescript.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' +import { dirname } from 'pathe' +import type { Ts } from './types' + +const typescriptCache = new Map() + +/** + * 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 +} diff --git a/packages/extension/src/snapshot/matchers.ts b/packages/extension/src/snapshot/matchers.ts new file mode 100644 index 00000000..e552e9a4 --- /dev/null +++ b/packages/extension/src/snapshot/matchers.ts @@ -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 = 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 = new Set([ + ...ExternalMatchers, + 'toMatchInlineSnapshot', + 'toThrowErrorMatchingInlineSnapshot', + 'toMatchFileSnapshot', +]) diff --git a/packages/extension/src/snapshot/parseSnapshotFile.ts b/packages/extension/src/snapshot/parseSnapshotFile.ts new file mode 100644 index 00000000..3b7b0197 --- /dev/null +++ b/packages/extension/src/snapshot/parseSnapshotFile.ts @@ -0,0 +1,105 @@ +/** + * Parsing helpers for Vitest/Jest `.snap` files. + * + * Vitest writes snapshots as `exports[``] = ``;` 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) +} diff --git a/packages/extension/src/snapshot/resolveSnapshotAt.ts b/packages/extension/src/snapshot/resolveSnapshotAt.ts new file mode 100644 index 00000000..2f3ee749 --- /dev/null +++ b/packages/extension/src/snapshot/resolveSnapshotAt.ts @@ -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 { + 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, + } +} diff --git a/packages/extension/src/snapshot/resolveSnapshotCall.ts b/packages/extension/src/snapshot/resolveSnapshotCall.ts new file mode 100644 index 00000000..c5f7fe82 --- /dev/null +++ b/packages/extension/src/snapshot/resolveSnapshotCall.ts @@ -0,0 +1,246 @@ +import type { CallExpression, Node, PropertyAccessExpression, SourceFile } from 'typescript' +import { CounterMatchers, ExternalMatchers, type SnapshotMatcher } from './matchers' +import type { Ts } from './types' + +export interface SnapshotCallResolution { + matcher: SnapshotMatcher + /** Full Vitest snapshot key, e.g. `Outer > Inner > does a thing 1`. */ + key: string + /** Offset range of the matcher identifier itself. */ + nameStart: number + nameEnd: number +} + +const SuiteNames = new Set(['describe', 'suite']) +const TestNames = new Set(['it', 'test']) +const DynamicModifiers = new Set(['each', 'for']) + +/** Sentinel returned when a title/hint cannot be evaluated statically. */ +const DYNAMIC = Symbol('dynamic') + +interface CalleeInfo { + base: string + modifiers: string[] +} + +interface MatcherAt { + call: CallExpression + matcher: SnapshotMatcher + nameStart: number + nameEnd: number +} + +function getMatcherName(ts: Ts, call: CallExpression): string | undefined { + const expression = call.expression + if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) { + if (CounterMatchers.has(expression.name.text)) { + return expression.name.text + } + } + return undefined +} + +/** Resolve the base callee name (`it`, `describe`, ...) and its modifier chain. */ +function getCalleeInfo(ts: Ts, call: CallExpression): CalleeInfo | undefined { + let expression: Node = call.expression + const modifiers: string[] = [] + // unwrap `test.each([...])('title', fn)` / `test.each`...`(...)` + while (ts.isCallExpression(expression) || ts.isTaggedTemplateExpression(expression)) { + expression = ts.isCallExpression(expression) ? expression.expression : expression.tag + } + while (ts.isPropertyAccessExpression(expression)) { + if (ts.isIdentifier(expression.name)) modifiers.unshift(expression.name.text) + expression = expression.expression + } + if (!ts.isIdentifier(expression)) return undefined + return { base: expression.text, modifiers } +} + +function isDynamicCallee(info: CalleeInfo): boolean { + return info.modifiers.some((modifier) => DynamicModifiers.has(modifier)) +} + +/** `describe`/`suite`, plus their `test.describe` / `test.suite` aliases. */ +function isSuiteCallee(info: CalleeInfo): boolean { + return ( + SuiteNames.has(info.base) || + (TestNames.has(info.base) && info.modifiers.some((modifier) => SuiteNames.has(modifier))) + ) +} + +/** First argument as a plain string, or `undefined` for dynamic/non-string titles. */ +function getTitle(ts: Ts, call: CallExpression): string | undefined { + const first = call.arguments[0] + if (first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first))) { + return first.text + } + return undefined +} + +function getLastFunctionArg(ts: Ts, call: CallExpression): Node | undefined { + const last = call.arguments[call.arguments.length - 1] + if (last && (ts.isArrowFunction(last) || ts.isFunctionExpression(last))) return last + return undefined +} + +function isNodeWithin(sourceFile: SourceFile, node: Node, container: Node): boolean { + return ( + node.getStart(sourceFile) >= container.getStart(sourceFile) && + node.getEnd() <= container.getEnd() + ) +} + +function findEnclosingTest(ts: Ts, sourceFile: SourceFile, node: Node): CallExpression | undefined { + let current: Node | undefined = node.parent + while (current) { + if (ts.isCallExpression(current)) { + const info = getCalleeInfo(ts, current) + if (info && TestNames.has(info.base) && !isSuiteCallee(info)) { + const fn = getLastFunctionArg(ts, current) + if (fn && isNodeWithin(sourceFile, node, fn)) return current + } + } + current = current.parent + } + return undefined +} + +function collectSuiteTitles(ts: Ts, testCall: CallExpression): string[] | undefined { + const titles: string[] = [] + let current: Node | undefined = testCall.parent + while (current) { + if (ts.isCallExpression(current)) { + const info = getCalleeInfo(ts, current) + if (info && isSuiteCallee(info)) { + if (isDynamicCallee(info)) return undefined + const title = getTitle(ts, current) + if (title === undefined) return undefined + titles.unshift(title) + } + } + current = current.parent + } + return titles +} + +function isStringLiteral(ts: Ts, node: Node): node is Node & { text: string } { + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) +} + +/** Static string value of `node`. `undefined` when absent, or `DYNAMIC` when not a literal. */ +function staticString(ts: Ts, node: Node | undefined): string | undefined | typeof DYNAMIC { + if (!node) return undefined + return isStringLiteral(ts, node) ? node.text : DYNAMIC +} + +/** Extract the snapshot hint (`message`) argument for a matcher call. */ +function getHint( + ts: Ts, + matcher: string, + call: CallExpression, +): string | undefined | typeof DYNAMIC { + const args = call.arguments + if (matcher === 'toMatchInlineSnapshot') { + const [properties, inlineSnapshot, message] = args + // Vitest treats a string first argument as the inline snapshot, not a hint. + if (properties && isStringLiteral(ts, properties)) return staticString(ts, inlineSnapshot) + if (properties && ts.isObjectLiteralExpression(properties)) return staticString(ts, message) + return properties ? DYNAMIC : undefined + } + if (matcher === 'toThrowErrorMatchingInlineSnapshot' || matcher === 'toMatchFileSnapshot') { + return staticString(ts, args[1]) + } + if (args.length === 0) return undefined + const last = args[args.length - 1] + if (isStringLiteral(ts, last)) return last.text + // `toMatchSnapshot({ ... })` (property matchers) has no hint + if (matcher === 'toMatchSnapshot' && ts.isObjectLiteralExpression(last)) return undefined + return DYNAMIC +} + +function collectMatcherCalls(ts: Ts, body: Node): { call: CallExpression; matcher: string }[] { + const found: { call: CallExpression; matcher: string }[] = [] + const visit = (node: Node) => { + if (ts.isCallExpression(node)) { + const matcher = getMatcherName(ts, node) + if (matcher) found.push({ call: node, matcher }) + } + ts.forEachChild(node, visit) + } + visit(body) + return found +} + +/** External snapshot-matcher call whose matcher name contains `offset`. */ +function findMatcherCallAt(ts: Ts, sourceFile: SourceFile, offset: number): MatcherAt | undefined { + let found: MatcherAt | undefined + const visit = (node: Node) => { + if (found) return + if (ts.isCallExpression(node)) { + const matcher = getMatcherName(ts, node) + if (matcher && ExternalMatchers.has(matcher)) { + const nameNode = (node.expression as PropertyAccessExpression).name + const nameStart = nameNode.getStart(sourceFile) + const nameEnd = nameNode.getEnd() + if (offset >= nameStart && offset <= nameEnd) { + found = { call: node, matcher: matcher as SnapshotMatcher, nameStart, nameEnd } + } + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return found +} + +/** + * Resolve the Vitest snapshot key for the `toMatchSnapshot` / + * `toThrowErrorMatchingSnapshot` call at `offset`. + * + * The key format matches `@vitest/snapshot`: + * `[, testTitle, ...(hint ? [hint] : [])].join(' > ') + ' ' + index` + * where `index` counts preceding snapshot assertions with the same title+hint, + * including inline and file snapshots since those share the same counter. + * + * Returns `undefined` when the call is not inside a test or uses dynamic titles/hints. + */ +export function resolveSnapshotCall( + ts: Ts, + sourceFile: SourceFile, + offset: number, +): SnapshotCallResolution | undefined { + const matcherAt = findMatcherCallAt(ts, sourceFile, offset) + if (!matcherAt) return undefined + + const testCall = findEnclosingTest(ts, sourceFile, matcherAt.call) + if (!testCall) return undefined + + const testInfo = getCalleeInfo(ts, testCall) + const testTitle = getTitle(ts, testCall) + if (!testInfo || isDynamicCallee(testInfo) || testTitle === undefined) return undefined + + const suiteTitles = collectSuiteTitles(ts, testCall) + if (!suiteTitles) return undefined + + const hint = getHint(ts, matcherAt.matcher, matcherAt.call) + if (hint === DYNAMIC) return undefined + + const body = getLastFunctionArg(ts, testCall) + if (!body) return undefined + + let index = 1 + for (const other of collectMatcherCalls(ts, body)) { + if (other.call === matcherAt.call) break + const otherHint = getHint(ts, other.matcher, other.call) + if (otherHint === DYNAMIC) return undefined // cannot tell if it shares this counter + if (otherHint === hint) index += 1 + } + + const testName = [...suiteTitles, testTitle, ...(hint !== undefined ? [hint] : [])].join(' > ') + return { + matcher: matcherAt.matcher, + key: `${testName} ${index}`, + nameStart: matcherAt.nameStart, + nameEnd: matcherAt.nameEnd, + } +} diff --git a/packages/extension/src/snapshot/resolveSnapshotPath.ts b/packages/extension/src/snapshot/resolveSnapshotPath.ts new file mode 100644 index 00000000..46c00937 --- /dev/null +++ b/packages/extension/src/snapshot/resolveSnapshotPath.ts @@ -0,0 +1,14 @@ +import { basename, dirname, join } from 'pathe' + +export const defaultSnapshotDir = '__snapshots__' + +/** + * Default Vitest snapshot location next to the test file: + * `/__snapshots__/.snap`. + * + * A custom `resolveSnapshotPath` or snapshot environment from the Vitest config + * is not supported. + */ +export function resolveSnapshotPath(testFile: string, snapshotDir = defaultSnapshotDir): string { + return join(dirname(testFile), snapshotDir, `${basename(testFile)}.snap`) +} diff --git a/packages/extension/src/snapshot/tools.ts b/packages/extension/src/snapshot/tools.ts index 0f3eedaa..3a173d08 100644 --- a/packages/extension/src/snapshot/tools.ts +++ b/packages/extension/src/snapshot/tools.ts @@ -1,75 +1,53 @@ import * as vscode from 'vscode' - -const ExportSymbolRegex = /^exports\[`([^`]*)`\]/gm -const RangeEndRegex = /`;$/m +import { parseSnapshotEntries } from './parseSnapshotFile' export interface SnapshotEntry { name: string breadcrumb: [...describeName: string[], itName: string] start: number end: number + body: string fullRange: vscode.Range keyRange: vscode.Range + bodyRange: vscode.Range } export class SnapshotEntryTool { private latestUri: string | undefined = undefined private latestVersion: number | undefined = undefined snapshotEntries: SnapshotEntry[] = [] + process( document: vscode.TextDocument, uri: string, version: number, token: vscode.CancellationToken, ): void { - let changeUri = false - let changeVersion = false - if (this.latestUri !== uri) { - this.latestUri = uri - this.latestVersion = version - changeUri = true - changeVersion = true - } else if (this.latestVersion !== version) { - this.latestVersion = version - changeVersion = true - } - - if (!changeUri && !changeVersion) { + if (this.latestUri === uri && this.latestVersion === version) { return // cached - } else { - // reset snapshotEntries - this.snapshotEntries = [] } - if (token.isCancellationRequested) return // cancelled - const text = document.getText() - const exportsSymbols = text.matchAll(ExportSymbolRegex) || [] - - for (const match of exportsSymbols) { - const name = match[1] - const snapshotDataStart = match.index - const snapshotDataEnd = - snapshotDataStart + - // find the nearest closing delimiter - (text.slice(snapshotDataStart).match(RangeEndRegex)?.index ?? - // fallback to empty snapshot - 'exports[`'.length + name.length + '`]'.length + ' = `'.length + '""'.length) + - '`;'.length - - this.snapshotEntries.push({ - name: name, - breadcrumb: name.split(' > ') as [...describeName: string[], itName: string], - start: snapshotDataStart, - end: snapshotDataEnd, - fullRange: new vscode.Range( - document.positionAt(snapshotDataStart), - document.positionAt(snapshotDataEnd), - ), - keyRange: new vscode.Range( - document.positionAt(snapshotDataStart + 'exports[`'.length), - document.positionAt(snapshotDataStart + 'exports[`'.length + name.length), - ), - }) + if (token.isCancellationRequested) { + return // cancelled: keep the previous cache untouched } + + this.snapshotEntries = parseSnapshotEntries(document.getText()).map((entry) => ({ + name: entry.name, + breadcrumb: entry.name.split(' > ') as [...describeName: string[], itName: string], + start: entry.start, + end: entry.end, + body: entry.body, + fullRange: new vscode.Range(document.positionAt(entry.start), document.positionAt(entry.end)), + keyRange: new vscode.Range( + document.positionAt(entry.keyStart), + document.positionAt(entry.keyEnd), + ), + bodyRange: new vscode.Range( + document.positionAt(entry.contentStart), + document.positionAt(entry.contentEnd), + ), + })) + this.latestUri = uri + this.latestVersion = version } } diff --git a/packages/extension/src/snapshot/types.ts b/packages/extension/src/snapshot/types.ts new file mode 100644 index 00000000..5bbbcf8a --- /dev/null +++ b/packages/extension/src/snapshot/types.ts @@ -0,0 +1,2 @@ +/** The TypeScript compiler API loaded from the workspace. */ +export type Ts = typeof import('typescript') diff --git a/test/unit/fixtures/snapshot/__snapshots__/basic.spec.ts.snap b/test/unit/fixtures/snapshot/__snapshots__/basic.spec.ts.snap new file mode 100644 index 00000000..c60f0b9e --- /dev/null +++ b/test/unit/fixtures/snapshot/__snapshots__/basic.spec.ts.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Outer > Inner > does a thing 1`] = `"1"`; + +exports[`Outer > Inner > does a thing 3`] = `"3"`; + +exports[`Outer > Inner > does a thing 4`] = `"4"`; + +exports[`Outer > Inner > does a thing > second 1`] = ` +{ + "a": 1, +} +`; + +exports[`Outer > top level it 1`] = ` +{ + "a": Any, +} +`; + +exports[`handles \`code\` in title 1`] = `"0"`; + +exports[`modified block 1`] = `"x"`; + +exports[`no describe 1`] = `"x"`; diff --git a/test/unit/fixtures/snapshot/basic.spec.ts b/test/unit/fixtures/snapshot/basic.spec.ts new file mode 100644 index 00000000..9871475c --- /dev/null +++ b/test/unit/fixtures/snapshot/basic.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +describe('Outer', () => { + describe('Inner', () => { + it('does a thing', () => { + expect('1').toMatchSnapshot() + expect('2').toMatchInlineSnapshot('2') + expect('3').toMatchSnapshot() + expect({ a: 1 }).toMatchSnapshot('second') + expect('4').toThrowErrorMatchingSnapshot() + }) + }) + + it('top level it', () => { + expect({ a: 1 }).toMatchSnapshot({ a: expect.any(Number) }) + }) +}) + +it('no describe', () => { + expect('x').toMatchSnapshot() +}) + +it.only('modified block', () => { + expect('x').toMatchSnapshot() +}) + +it('handles `code` in title', () => { + expect('0').toMatchSnapshot() +}) + +it.each([1, 2])('dynamic %s', () => { + expect('x').toMatchSnapshot() +}) diff --git a/test/unit/snapshot.test.ts b/test/unit/snapshot.test.ts new file mode 100644 index 00000000..3ae0f627 --- /dev/null +++ b/test/unit/snapshot.test.ts @@ -0,0 +1,280 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'chai' +import ts from 'typescript' +import { + findSnapshotEntry, + parseSnapshotEntries, +} from '../../packages/extension/src/snapshot/parseSnapshotFile' +import { resolveSnapshotCall } from '../../packages/extension/src/snapshot/resolveSnapshotCall' +import { resolveSnapshotPath } from '../../packages/extension/src/snapshot/resolveSnapshotPath' + +const fixtureDir = join(__dirname, 'fixtures', 'snapshot') +const testFile = join(fixtureDir, 'basic.spec.ts') +const sourceText = readFileSync(testFile, 'utf8') +const snapshotText = readFileSync( + join(fixtureDir, '__snapshots__', 'basic.spec.ts.snap'), + 'utf8', +).replace(/\r\n/g, '\n') +const sourceFile = ts.createSourceFile( + testFile, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, +) +const snapshotEntries = parseSnapshotEntries(snapshotText) + +/** Character offset in the middle of the nth (1-based) occurrence of `needle`. */ +function offsetOf(needle: string, occurrence = 1): number { + let index = -1 + for (let i = 0; i < occurrence; i++) { + index = sourceText.indexOf(needle, index + 1) + if (index === -1) throw new Error(`not found: ${needle} #${occurrence}`) + } + return index + Math.floor(needle.length / 2) +} + +function keyAt(needle: string, occurrence = 1): string | undefined { + return resolveSnapshotCall(ts, sourceFile, offsetOf(needle, occurrence))?.key +} + +/** Resolve the key at the middle of the nth (1-based) `needle` in a synthetic source. */ +function keyInSource(source: string, needle: string, occurrence = 1): string | undefined { + const file = ts.createSourceFile( + 'inline.spec.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + let index = -1 + for (let i = 0; i < occurrence; i++) { + index = source.indexOf(needle, index + 1) + if (index === -1) throw new Error(`not found: ${needle} #${occurrence}`) + } + return resolveSnapshotCall(ts, file, index + Math.floor(needle.length / 2))?.key +} + +describe('resolveSnapshotCall', () => { + it('builds nested describe > it keys with per-test counters', () => { + expect(keyAt('toMatchSnapshot', 1)).to.equal('Outer > Inner > does a thing 1') + expect(keyAt('toMatchSnapshot', 2)).to.equal('Outer > Inner > does a thing 3') + }) + + it('appends the hint and counts hinted calls separately', () => { + expect(keyAt('toMatchSnapshot', 3)).to.equal('Outer > Inner > does a thing > second 1') + }) + + it('keeps the no-hint counter independent from hinted calls', () => { + expect(keyAt('toThrowErrorMatchingSnapshot', 1)).to.equal('Outer > Inner > does a thing 4') + }) + + it('ignores property matchers for the key', () => { + expect(keyAt('toMatchSnapshot', 4)).to.equal('Outer > top level it 1') + }) + + it('supports top-level it', () => { + expect(keyAt('toMatchSnapshot', 5)).to.equal('no describe 1') + }) + + it('supports it.only', () => { + expect(keyAt('toMatchSnapshot', 6)).to.equal('modified block 1') + }) + + it('handles backticks inside the test title', () => { + expect(keyAt('toMatchSnapshot', 7)).to.equal('handles `code` in title 1') + }) + + it('returns undefined for dynamic it.each titles', () => { + expect(keyAt('toMatchSnapshot', 8)).to.equal(undefined) + }) + + it('returns undefined away from a matcher', () => { + expect(resolveSnapshotCall(ts, sourceFile, sourceText.indexOf('describe'))).to.equal(undefined) + }) +}) + +describe('resolveSnapshotCall matcher counters', () => { + it('counts a preceding inline snapshot toward the external index', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('mixed', () => {`, + ` expect('a').toMatchSnapshot()`, + ` expect('b').toMatchInlineSnapshot(\`"b"\`)`, + ` expect('c').toMatchSnapshot()`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot', 2)).to.equal('mixed 3') + }) + + it('counts a preceding file snapshot toward the external index', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('file', () => {`, + ` expect('a').toMatchFileSnapshot('./a.txt')`, + ` expect('b').toMatchSnapshot()`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('file 2') + }) + + it('counts a preceding inline throw snapshot toward the external index', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('throwing', () => {`, + ` expect(() => { throw new Error('x') }).toThrowErrorMatchingInlineSnapshot()`, + ` expect('b').toMatchSnapshot()`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('throwing 2') + }) + + it('treats a string first argument of toMatchInlineSnapshot as the snapshot, not a hint', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('snap', () => {`, + ` expect('a').toMatchInlineSnapshot(\`"a"\`, 'hint')`, + ` expect('b').toMatchSnapshot('hint')`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('snap > hint 2') + }) + + it('does not count an inline snapshot that uses a different hint', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('diff', () => {`, + ` expect('a').toMatchInlineSnapshot(\`"a"\`, 'other')`, + ` expect('b').toMatchSnapshot()`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('diff 1') + }) + + it('counts property matchers with an explicit hint separately', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('props', () => {`, + ` expect({ a: 1 }).toMatchSnapshot({ a: expect.any(Number) }, 'hint')`, + ` expect({ a: 2 }).toMatchSnapshot({ a: expect.any(Number) }, 'hint')`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot', 2)).to.equal('props > hint 2') + }) + + it('bails out when a preceding snapshot uses a dynamic hint', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('dynamic', (hint: string) => {`, + ` expect('a').toMatchSnapshot(hint)`, + ` expect('b').toMatchSnapshot()`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot', 2)).to.equal(undefined) + }) + + it('recognizes test.describe as a suite', () => { + const source = [ + `import { expect, test } from 'vitest'`, + `test.describe('Outer', () => {`, + ` test('inner', () => {`, + ` expect('a').toMatchSnapshot()`, + ` })`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('Outer > inner 1') + }) + + it('recognizes suite as a describe alias', () => { + const source = [ + `import { expect, suite, test } from 'vitest'`, + `suite('Outer', () => {`, + ` test('inner', () => {`, + ` expect('a').toMatchSnapshot()`, + ` })`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toMatchSnapshot')).to.equal('Outer > inner 1') + }) + + it('reads the hint argument of toThrowErrorMatchingSnapshot', () => { + const source = [ + `import { expect, it } from 'vitest'`, + `it('throwing', () => {`, + ` expect(() => { throw new Error('x') }).toThrowErrorMatchingSnapshot('hint')`, + `})`, + ].join('\n') + expect(keyInSource(source, 'toThrowErrorMatchingSnapshot')).to.equal('throwing > hint 1') + }) +}) + +describe('parseSnapshotEntries', () => { + it('parses every exports block in order', () => { + expect(snapshotEntries.map((entry) => entry.name)).to.eql([ + 'Outer > Inner > does a thing 1', + 'Outer > Inner > does a thing 3', + 'Outer > Inner > does a thing 4', + 'Outer > Inner > does a thing > second 1', + 'Outer > top level it 1', + 'handles `code` in title 1', + 'modified block 1', + 'no describe 1', + ]) + }) + + it('reverses escaping in keys and bodies', () => { + const entry = findSnapshotEntry(snapshotEntries, 'handles `code` in title 1') + expect(entry?.body).to.equal('"0"') + }) + + it('extracts multiline bodies without surrounding backticks', () => { + const entry = findSnapshotEntry(snapshotEntries, 'Outer > top level it 1') + expect(entry?.body).to.equal('{\n "a": Any,\n}') + }) + + it('offsets point at the matching statement', () => { + const entry = findSnapshotEntry(snapshotEntries, 'no describe 1')! + expect(snapshotText.slice(entry.start, entry.end)).to.equal('exports[`no describe 1`] = `"x"`;') + }) + + it('raw body offsets include the padding newlines of multiline snapshots', () => { + const entry = findSnapshotEntry(snapshotEntries, 'Outer > top level it 1')! + expect(snapshotText.slice(entry.bodyStart, entry.bodyEnd)).to.equal( + '\n{\n "a": Any,\n}\n', + ) + }) + + it('content offsets point at the value without padding newlines', () => { + const entry = findSnapshotEntry(snapshotEntries, 'Outer > top level it 1')! + expect(snapshotText.slice(entry.contentStart, entry.contentEnd)).to.equal( + '{\n "a": Any,\n}', + ) + }) + + it('content offsets match the raw body for single-line snapshots', () => { + const entry = findSnapshotEntry(snapshotEntries, 'no describe 1')! + expect(entry.contentStart).to.equal(entry.bodyStart) + expect(entry.contentEnd).to.equal(entry.bodyEnd) + }) +}) + +describe('resolveSnapshotPath', () => { + it('uses __snapshots__/.snap by default', () => { + expect(resolveSnapshotPath('/a/b/foo.spec.ts')).to.equal('/a/b/__snapshots__/foo.spec.ts.snap') + }) + + it('honors a custom snapshot dir', () => { + expect(resolveSnapshotPath('/a/b/foo.spec.ts', '__snaps__')).to.equal( + '/a/b/__snaps__/foo.spec.ts.snap', + ) + }) +}) + +describe('resolve snapshot end to end', () => { + it('resolves the cursor to the stored snapshot value', () => { + const key = keyAt('toMatchSnapshot', 2) + const entry = key ? findSnapshotEntry(snapshotEntries, key) : undefined + expect(entry?.body).to.equal('"3"') + }) +})