Skip to content

Commit 667c139

Browse files
authored
fix(snapshot): support no-unsafe-eval CSP by evaluating snapshot files on server (#10665)
1 parent b298df6 commit 667c139

18 files changed

Lines changed: 194 additions & 49 deletions

File tree

docs/config/api.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,4 @@ If the host is set to anything other than `localhost` or `127.0.0.1`, Vitest wil
3131
- **Type:** `boolean`
3232
- **Default:** `true` if not exposed to the network, `false` otherwise
3333

34-
Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. This option also gates privileged browser APIs that can execute code indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp).
35-
34+
Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. In Browser Mode, this option also gates indirect code execution, including evaluating external `.snap` files on the server and accessing raw Chrome DevTools Protocol through [`cdp()`](/api/browser/context#cdp).

packages/browser/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"@types/node": "catalog:",
9393
"@types/pngjs": "^6.0.5",
9494
"@types/ws": "catalog:",
95+
"@vitest/snapshot": "workspace:*",
9596
"birpc": "catalog:",
9697
"flatted": "catalog:",
9798
"ivya": "^1.8.2",

packages/browser/src/client/tester/snapshot.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export class VitestBrowserSnapshotEnvironment implements SnapshotEnvironment {
2323
return rpc().readSnapshotFile(filepath)
2424
}
2525

26+
// Evaluate snapshots on the server because CSP may block `new Function` in the browser.
27+
readSnapshotFileData(filepath: string): Promise<Record<string, string> | null> {
28+
return rpc().readSnapshotFileData(filepath)
29+
}
30+
2631
saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
2732
return rpc().saveSnapshotFile(filepath, snapshot)
2833
}

packages/browser/src/node/rpc.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { BrowserServerState } from './state'
1010
import { existsSync, promises as fs } from 'node:fs'
1111
import { AutomockedModule, AutospiedModule, ManualMockedModule, RedirectedModule } from '@vitest/mocker'
1212
import { ServerMockResolver } from '@vitest/mocker/node'
13+
import { evaluateSnapshotFile } from '@vitest/snapshot/environment'
1314
import { extractSourcemapFromFile } from '@vitest/utils/source-map/node'
1415
import { createBirpc } from 'birpc'
1516
import { parse, stringify } from 'flatted'
@@ -130,15 +131,17 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
130131
)
131132
}
132133

133-
function isCdpAllowed(project: TestProject) {
134+
function canExec(project: TestProject) {
134135
return (
135136
project.config.api.allowExec
136137
&& project.vitest.config.api.allowExec
137-
&& project.config.api.allowWrite
138-
&& project.vitest.config.api.allowWrite
139138
)
140139
}
141140

141+
function isCdpAllowed(project: TestProject) {
142+
return canExec(project) && canWrite(project)
143+
}
144+
142145
function assertCdpAllowed(project: TestProject) {
143146
if (!isCdpAllowed(project)) {
144147
throw new Error(
@@ -276,6 +279,19 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
276279
}
277280
return fs.readFile(snapshotPath, 'utf-8')
278281
},
282+
async readSnapshotFileData(snapshotPath) {
283+
checkFileAccess(snapshotPath)
284+
if (!existsSync(snapshotPath)) {
285+
return null
286+
}
287+
if (!canExec(project)) {
288+
throw new Error(
289+
`Cannot read snapshot file because browser API exec operations are disabled. See https://vitest.dev/config/api.`,
290+
)
291+
}
292+
const content = await fs.readFile(snapshotPath, 'utf-8')
293+
return evaluateSnapshotFile(snapshotPath, content)
294+
},
279295
async saveSnapshotFile(id, content) {
280296
checkFileAccess(id)
281297
if (!canWrite(project)) {

packages/browser/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface WebSocketBrowserHandlers {
3232
cancelCurrentRun: (reason: CancelReason) => void
3333
getCountOfFailedTests: () => number
3434
readSnapshotFile: (id: string) => Promise<string | null>
35+
readSnapshotFileData: (id: string) => Promise<Record<string, string> | null>
3536
saveSnapshotFile: (id: string, content: string) => Promise<void>
3637
removeSnapshotFile: (id: string) => Promise<void>
3738
sendLog: (method: TestExecutionMethod, log: UserConsoleLog) => void
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { NodeSnapshotEnvironment } from './env/node'
2+
export { evaluateSnapshotFile } from './port/utils'
23
export type { SnapshotEnvironment } from './types/environment'

packages/snapshot/src/port/state.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
addExtraLineBreaks,
2727
CounterMap,
2828
DefaultMap,
29-
getSnapshotData,
29+
evaluateSnapshotFile,
3030
keyToTestName,
3131
normalizeNewlines,
3232
removeExtraLineBreaks,
@@ -95,11 +95,13 @@ export default class SnapshotState {
9595
private constructor(
9696
public testFilePath: string,
9797
public snapshotPath: string,
98-
snapshotContent: string | null,
98+
fileData: SnapshotData | null,
9999
options: SnapshotStateOptions,
100100
) {
101-
const { data, dirty } = getSnapshotData(snapshotContent, options)
102-
this._fileExists = snapshotContent != null // TODO: update on watch?
101+
const data = fileData ?? Object.create(null)
102+
this._fileExists = fileData != null // TODO: update on watch?
103+
const update = options.updateSnapshot
104+
const dirty = (update === 'all' || update === 'new') && fileData != null
103105
this._initialData = { ...data }
104106
this._snapshotData = { ...data }
105107
this._dirty = dirty
@@ -122,13 +124,17 @@ export default class SnapshotState {
122124
}
123125

124126
static async create(testFilePath: string, options: SnapshotStateOptions): Promise<SnapshotState> {
125-
const snapshotPath = await options.snapshotEnvironment.resolvePath(
126-
testFilePath,
127-
)
128-
const content = await options.snapshotEnvironment.readSnapshotFile(
129-
snapshotPath,
130-
)
131-
return new SnapshotState(testFilePath, snapshotPath, content, options)
127+
const environment = options.snapshotEnvironment
128+
const snapshotPath = await environment.resolvePath(testFilePath)
129+
let fileData: SnapshotData | null
130+
if (environment.readSnapshotFileData) {
131+
fileData = await environment.readSnapshotFileData(snapshotPath)
132+
}
133+
else {
134+
const content = await environment.readSnapshotFile(snapshotPath)
135+
fileData = content != null ? evaluateSnapshotFile(snapshotPath, content) : null
136+
}
137+
return new SnapshotState(testFilePath, snapshotPath, fileData, options)
132138
}
133139

134140
get snapshotUpdateState(): SnapshotUpdateState {

packages/snapshot/src/port/utils.ts

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import type { OptionsReceived as PrettyFormatOptions } from '@vitest/pretty-format'
9-
import type { SnapshotData, SnapshotStateOptions } from '../types'
9+
import type { SnapshotData } from '../types'
1010
import type { SnapshotEnvironment } from '../types/environment'
1111
import { format as prettyFormat } from '@vitest/pretty-format'
1212
import { isObject } from '@vitest/utils/helpers'
@@ -27,39 +27,24 @@ export function keyToTestName(key: string): string {
2727
return key.replace(/ \d+$/, '')
2828
}
2929

30-
export function getSnapshotData(
31-
content: string | null,
32-
options: SnapshotStateOptions,
33-
): {
34-
data: SnapshotData
35-
dirty: boolean
36-
} {
37-
const update = options.updateSnapshot
30+
// Evaluate a snapshot file's content into its snapshot key/value pairs.
31+
export function evaluateSnapshotFile(
32+
filepath: string,
33+
content: string,
34+
): SnapshotData {
3835
const data = Object.create(null)
39-
let snapshotContents = ''
40-
let dirty = false
41-
42-
if (content != null) {
43-
try {
44-
snapshotContents = content
45-
// eslint-disable-next-line no-new-func
46-
const populate = new Function('exports', snapshotContents)
47-
populate(data)
48-
}
49-
catch {}
36+
try {
37+
// eslint-disable-next-line no-new-func
38+
const populate = new Function('exports', content)
39+
populate(data)
5040
}
51-
52-
// const validationResult = validateSnapshotVersion(snapshotContents)
53-
const isInvalid = snapshotContents // && validationResult
54-
55-
// if (update === 'none' && isInvalid)
56-
// throw validationResult
57-
58-
if ((update === 'all' || update === 'new') && isInvalid) {
59-
dirty = true
41+
catch (cause) {
42+
throw new Error(
43+
`Invalid snapshot file, please manually fix or delete it: ${filepath}`,
44+
{ cause },
45+
)
6046
}
61-
62-
return { data, dirty }
47+
return data
6348
}
6449

6550
// Add extra line breaks at beginning and end of multiline snapshot

packages/snapshot/src/types/environment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface SnapshotEnvironment {
77
resolveRawPath: (testPath: string, rawPath: string) => Promise<string>
88
saveSnapshotFile: (filepath: string, snapshot: string) => Promise<void>
99
readSnapshotFile: (filepath: string) => Promise<string | null>
10+
// Allows environments to evaluate snapshots outside the test runtime.
11+
readSnapshotFileData?: (filepath: string) => Promise<Record<string, string> | null>
1012
removeSnapshotFile: (filepath: string) => Promise<void>
1113
processStackTrace?: (stack: ParsedStack) => ParsedStack
1214
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo