返回 slidev
walker.test.ts
根目录 / packages / slidev / node / commands / pptx / walker.test.ts
1 import fs from 'node:fs'
2 import { dirname, join } from 'node:path'
3 import { fileURLToPath } from 'node:url'
4 import vm from 'node:vm'
5 import { describe, expect, it } from 'vitest'
6 import { collectSnapshot } from './walker'
7
8 /**
9 * The walker is serialized by Playwright and evaluated in a page where this
10 * module's scope does not exist, so a free variable becomes a `ReferenceError`
11 * inside someone else's browser. The guard has to run the reconstructed
12 * function, not compile it: an undeclared identifier resolves at call time, so
13 * `new Function(source)` accepts a body full of them. The sandbox global is a
14 * Proxy that throws on any identifier outside a browser allowlist.
15 *
16 * What the walker extracts is not tested here. jsdom has no layout engine, so
17 * `getBoundingClientRect` returns zeros and a DOM test would pass with every
18 * rect at the origin. The judgement it feeds lives in `normalize.ts`.
19 */
20
21 /** Globals a browser really provides. Anything else is a leak. */
22 const BROWSER_GLOBALS = new Set([
23 'document',
24 'window',
25 'getComputedStyle',
26 'Array',
27 'Boolean',
28 'Error',
29 'JSON',
30 'Map',
31 'Math',
32 'Number',
33 'Object',
34 'RegExp',
35 'Set',
36 'String',
37 'Symbol',
38 'undefined',
39 'NaN',
40 'Infinity',
41 'globalThis',
42 ])
43
44 function measureTextStub(): { width: number } {
45 return { width: 100 }
46 }
47
48 /**
49 * A canvas whose text measurement changes only for the families named, which is
50 * how `isAvailable` tells a font that exists from one that fell through to the
51 * base. Everything else keeps the constant width, so it reads as absent.
52 */
53 function installedFonts(...families: string[]) {
54 return function (this: { font: string }): { width: number } {
55 return { width: families.some(f => this.font.includes(`"${f}"`)) ? 140 : 100 }
56 }
57 }
58
59 const RECT = { left: 0, top: 0, width: 100, height: 20 }
60
61 /**
62 * A slide container with one element and one text node under it.
63 *
64 * Not decoration: with an empty container list the guard only ever ran the
65 * walker's top-level setup, so every free variable inside `walk` and its
66 * helpers went unnoticed. `window` was one of them, referenced for `scrollX`
67 * and absent from the allowlist, and the suite passed regardless.
68 */
69 function containerStub() {
70 const text = { nodeType: 3, textContent: 'hello' }
71 const child: any = {
72 nodeType: 1,
73 tagName: 'DIV',
74 id: '',
75 childNodes: [text],
76 children: [],
77 classList: { contains: () => false },
78 shadowRoot: null,
79 parentElement: null,
80 setAttribute: () => {},
81 getAttribute: () => null,
82 getBoundingClientRect: () => RECT,
83 getClientRects: () => [RECT],
84 }
85 const container: any = {
86 nodeType: 1,
87 tagName: 'DIV',
88 id: '003-02',
89 childNodes: [child],
90 children: [child],
91 classList: { contains: () => false },
92 shadowRoot: null,
93 parentElement: null,
94 setAttribute: () => {},
95 getAttribute: () => null,
96 getBoundingClientRect: () => ({ ...RECT, width: 980, height: 552 }),
97 getClientRects: () => [RECT],
98 }
99 child.parentElement = container
100 return container
101 }
102
103 /** Just enough DOM for the walker to run to completion. */
104 function domStub(querySelectorAllResult: unknown[], measureText = measureTextStub) {
105 return {
106 createElement: () => ({
107 getContext: () => ({
108 font: '',
109 fillStyle: '',
110 measureText,
111 clearRect: () => {},
112 fillRect: () => {},
113 getImageData: () => ({ data: [0, 0, 0, 0] }),
114 }),
115 }),
116 querySelectorAll: () => querySelectorAllResult,
117 createRange: () => ({
118 selectNodeContents: () => {},
119 getClientRects: () => [RECT],
120 detach: () => {},
121 }),
122 }
123 }
124
125 interface SandboxOptions {
126 measureText?: () => { width: number }
127 fontFamily?: string
128 }
129
130 function runInSandbox(source: string, containers: unknown[] = [], options: SandboxOptions = {}): unknown {
131 const base: Record<string, unknown> = {
132 document: domStub(containers, options.measureText),
133 getComputedStyle: () => ({
134 fontFamily: options.fontFamily ?? 'Inter, sans-serif',
135 display: 'block',
136 visibility: 'visible',
137 opacity: '1',
138 position: 'static',
139 backgroundColor: 'rgb(255, 255, 255)',
140 listStyleType: 'none',
141 content: 'none',
142 width: '100px',
143 height: '20px',
144 left: 'auto',
145 top: 'auto',
146 right: 'auto',
147 bottom: 'auto',
148 }),
149 Array,
150 Boolean,
151 Error,
152 JSON,
153 Map,
154 Math,
155 Number,
156 Object,
157 RegExp,
158 Set,
159 String,
160 Symbol,
161 globalThis: undefined,
162 window: { scrollX: 0, scrollY: 0 },
163 }
164
165 const sandbox = new Proxy(base, {
166 // Claim every name so V8 resolves lookups here rather than walking out.
167 has: () => true,
168 get(target, prop) {
169 if (typeof prop === 'symbol')
170 return (target as any)[prop]
171 if (BROWSER_GLOBALS.has(prop))
172 return target[prop]
173 throw new ReferenceError(
174 `walker referenced "${String(prop)}", which will not exist inside page.evaluate`,
175 )
176 },
177 })
178
179 const context = vm.createContext(sandbox)
180 const fn = vm.runInContext(`(${source})`, context)
181 return fn({ containerSelector: '.print-slide-container', idAttribute: 'data-slidev-export-id' })
182 }
183
184 describe('walker self-containedness', () => {
185 it('runs to completion using only browser globals', () => {
186 expect(() => runInSandbox(collectSnapshot.toString())).not.toThrow()
187 })
188
189 it('runs to completion over a real container, not just an empty page', () => {
190 // The empty-page case exercises none of `walk`, which is most of the file.
191 const snapshot = runInSandbox(collectSnapshot.toString(), [containerStub()]) as any
192 expect(snapshot.slides).toHaveLength(1)
193 expect(snapshot.slides[0].nodes.length).toBeGreaterThan(0)
194 })
195
196 it('fails when a walked node closes over module scope', () => {
197 // The same guard as below, but pinned on a path only a container reaches.
198 const leaked = collectSnapshot
199 .toString()
200 // The record built for every ELEMENT, so it is reached only by walking a
201 // container. Matched on its first field because the compiled source has
202 // two other `const record` declarations, in the style interners, which a
203 // page with no pseudo-elements never runs.
204 .replace(/const record\s*(?:(: any)\s*)?=\s*\{\s*id,/, 'const record = { leaked: LEAKED_FROM_WALK, id,')
205 expect(() => runInSandbox(leaked, [containerStub()])).toThrow(/LEAKED_FROM_WALK/)
206 })
207
208 it('fails when the walker closes over module scope', () => {
209 // The mutation this guard exists to catch, pinned so the guard itself
210 // cannot silently stop working.
211 const leaked = collectSnapshot
212 .toString()
213 .replace('const styles', 'const styles = [LEAKED_FROM_MODULE_SCOPE]; const _unused')
214 expect(() => runInSandbox(leaked)).toThrow(/LEAKED_FROM_MODULE_SCOPE/)
215 })
216
217 it('returns the expected snapshot shape', () => {
218 const snapshot = runInSandbox(collectSnapshot.toString()) as any
219 expect(snapshot).toHaveProperty('slides')
220 expect(snapshot).toHaveProperty('styles')
221 expect(snapshot).toHaveProperty('fontResolution')
222 })
223
224 it('detects a font the theme ships as a webfont, and names it without the suffix', () => {
225 // `@fontsource-variable/inter` registers the family as "Inter Variable",
226 // so probing the stripped name first asks whether a static "Inter" is
227 // installed. On a machine with no such copy, which is the normal case for
228 // a webfont, nothing in the stack matched and every run in the deck was
229 // written out as a system fallback instead of the theme's own face.
230 const snapshot = runInSandbox(collectSnapshot.toString(), [containerStub()], {
231 fontFamily: '"Inter Variable", Inter, sans-serif',
232 measureText: installedFonts('Inter Variable'),
233 }) as any
234 expect(snapshot.fontResolution['"Inter Variable", Inter, sans-serif']).toBe('Inter')
235 })
236
237 it('still resolves a family installed under its plain name', () => {
238 // The Google Fonts path, which registers "Inter" with no suffix. Every
239 // Slidev-themed deck takes it, which is why the bug above stayed invisible
240 // until a theme shipped its font through `@fontsource`.
241 const snapshot = runInSandbox(collectSnapshot.toString(), [containerStub()], {
242 fontFamily: 'Inter, sans-serif',
243 measureText: installedFonts('Inter'),
244 }) as any
245 expect(snapshot.fontResolution['Inter, sans-serif']).toBe('Inter')
246 })
247
248 it('reports no family when nothing in the stack resolves', () => {
249 const snapshot = runInSandbox(collectSnapshot.toString(), [containerStub()], {
250 fontFamily: '"Human Sans", sans-serif',
251 measureText: installedFonts('Something Else'),
252 }) as any
253 expect(snapshot.fontResolution['"Human Sans", sans-serif']).toBe('')
254 })
255
256 it('carries no bundler-injected identifiers', () => {
257 // tsdown and Vite rewrite bodies with helpers such as `__toESM`,
258 // `__publicField` or `__name`, none of which exist in the page.
259 expect(collectSnapshot.toString().match(/\b__\w+/g)).toBeNull()
260 })
261
262 it('does not reference the module registry', () => {
263 const source = collectSnapshot.toString()
264 for (const forbidden of ['require(', 'import(', 'exports.', 'module.exports'])
265 expect(source).not.toContain(forbidden)
266 })
267 })
268
269 /**
270 * The walker as it is actually shipped.
271 *
272 * Everything above runs against Vitest's transform of the source, which is not
273 * what reaches a browser: `exportPptxEditable` hands Playwright the function
274 * from the bundle in `dist`, and a bundler is free to rewrite a body with
275 * helpers of its own. Testing only the transform leaves the invariant
276 * unenforced on the one artifact that matters.
277 *
278 * Needs a prior `pnpm build`, which is how this repository runs its tests
279 * anyway: workspace packages resolve through their built `dist`.
280 */
281 function bundledWalker(): string {
282 const dist = join(dirname(fileURLToPath(import.meta.url)), '../../../dist')
283 const file = fs.readdirSync(dist).find(name => /^pptx-.+\.mjs$/.test(name))
284 if (!file)
285 throw new Error(`no built pptx bundle in ${dist}; run \`pnpm build\` first`)
286
287 const source = fs.readFileSync(join(dist, file), 'utf8')
288 const start = source.indexOf('function collectSnapshot')
289 if (start < 0)
290 throw new Error('collectSnapshot is not in the built bundle under its own name')
291
292 // Brace matching rather than a regex: the body is thousands of characters of
293 // nested functions and object literals.
294 let depth = 0
295 let index = source.indexOf('{', start)
296 const open = index
297 for (; index < source.length; index++) {
298 if (source[index] === '{')
299 depth++
300 else if (source[index] === '}' && --depth === 0)
301 break
302 }
303 return `function collectSnapshot${source.slice(source.indexOf('(', start), open)}${source.slice(open, index + 1)}`
304 }
305
306 /** Block comments only: enough for the bundler's annotations, and it cannot eat a regex literal. */
307 function withoutComments(source: string): string {
308 return source.replace(/\/\*[\s\S]*?\*\//g, '')
309 }
310
311 describe('the built bundle keeps the walker self-contained', () => {
312 it('runs to completion using only browser globals', () => {
313 expect(() => runInSandbox(bundledWalker(), [containerStub()])).not.toThrow()
314 })
315
316 it('carries no bundler-injected identifiers', () => {
317 // tsdown and Vite rewrite bodies with helpers such as `__toESM`,
318 // `__publicField` or `__name`, none of which exist in the page.
319 //
320 // Block comments are stripped first: the bundler annotates calls with
321 // `/* @__PURE__ */`, which never executes and is not a leak.
322 expect(withoutComments(bundledWalker()).match(/\b__\w+/g)).toBeNull()
323 })
324
325 it('does not reference the module registry', () => {
326 const source = withoutComments(bundledWalker())
327 for (const forbidden of ['require(', 'import(', 'exports.', 'module.exports'])
328 expect(source).not.toContain(forbidden)
329 })
330
331 it('fails when the bundle leaks a helper into the walker', () => {
332 // The guard pinned against the mutation it exists to catch, so it cannot
333 // quietly stop working the next time the bundler changes.
334 const leaked = bundledWalker().replace(/const nodes = \[\]/, 'const nodes = __name([])')
335 expect(() => runInSandbox(leaked, [containerStub()])).toThrow(/__name/)
336 })
337 })
338
338 lines TYPESCRIPT