| 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 |