@sorb/seed 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ // E3 — Playwright page lifecycle for the render worker.
2
+ //
3
+ // Reuses the SAME lazy-Playwright-import pattern as captureCli.js's
4
+ // loadChromium: `playwright` is an optional peer dep, so importing it must
5
+ // never happen at module-load time — only when a render is actually requested.
6
+ // This file is the ONLY place worker.js touches Playwright directly, so
7
+ // worker.js (and its tests) can import cleanly without the package installed.
8
+ //
9
+ // Page reuse (the "skip navigation" half of the diff-only optimization): one
10
+ // browser context is kept alive for the pool's lifetime, and one page per URL
11
+ // is kept open across calls — so a second render() for a URL we've already
12
+ // visited skips `page.goto` (the dominant chunk of the measured ~1.75s/render,
13
+ // per hosted-bridge-modes-exploration-plan.md E0) and only re-injects the token
14
+ // map + recaptures. Callers that want a fresh navigation (e.g. the app itself
15
+ // changed) should call `pool.evict(url)` first.
16
+
17
+ const loadChromium = async () => {
18
+ const { chromium } = await import('playwright')
19
+ return chromium
20
+ }
21
+
22
+ /**
23
+ * @typedef {object} PagePool
24
+ * @property {(url: string, viewport?: {width:number,height:number}) => Promise<{page: object, reused: boolean}>} acquire
25
+ * @property {(url: string) => Promise<void>} evict
26
+ * @property {() => Promise<void>} closeAll
27
+ */
28
+
29
+ /**
30
+ * Create a real, Playwright-backed page pool. Lazily launches the browser on
31
+ * the first `acquire()` call.
32
+ * @returns {PagePool}
33
+ */
34
+ export function createPagePool() {
35
+ /** @type {import('playwright').Browser|null} */
36
+ let browser = null
37
+ /** @type {import('playwright').BrowserContext|null} */
38
+ let ctx = null
39
+ /** @type {Map<string, import('playwright').Page>} */
40
+ const pages = new Map()
41
+
42
+ const ensureBrowser = async () => {
43
+ if (browser) return
44
+ const chromium = await loadChromium()
45
+ browser = await chromium.launch()
46
+ ctx = await browser.newContext()
47
+ }
48
+
49
+ return {
50
+ async acquire(url, viewport = { width: 1280, height: 800 }) {
51
+ await ensureBrowser()
52
+ const existing = pages.get(url)
53
+ if (existing) {
54
+ await existing.setViewportSize(viewport)
55
+ return { page: existing, reused: true }
56
+ }
57
+ const page = await ctx.newPage()
58
+ await page.setViewportSize(viewport)
59
+ await page.goto(url, { waitUntil: 'load' })
60
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
61
+ pages.set(url, page)
62
+ return { page, reused: false }
63
+ },
64
+
65
+ async evict(url) {
66
+ const page = pages.get(url)
67
+ if (page) {
68
+ await page.close().catch(() => {})
69
+ pages.delete(url)
70
+ }
71
+ },
72
+
73
+ async closeAll() {
74
+ for (const page of pages.values()) await page.close().catch(() => {})
75
+ pages.clear()
76
+ if (ctx) await ctx.close().catch(() => {})
77
+ if (browser) await browser.close().catch(() => {})
78
+ browser = null
79
+ ctx = null
80
+ },
81
+ }
82
+ }
@@ -0,0 +1,23 @@
1
+ // pagePool.js touches Playwright lazily (only inside acquire()); these tests
2
+ // only cover the laziness/shape contract — they never call acquire(), so they
3
+ // never launch a real browser and pass whether or not Playwright is installed.
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { createPagePool } from './pagePool.js'
7
+
8
+ test('createPagePool: returns a pool without importing/launching Playwright', () => {
9
+ const pool = createPagePool()
10
+ assert.equal(typeof pool.acquire, 'function')
11
+ assert.equal(typeof pool.evict, 'function')
12
+ assert.equal(typeof pool.closeAll, 'function')
13
+ })
14
+
15
+ test('createPagePool: closeAll on a never-used pool is a safe no-op', async () => {
16
+ const pool = createPagePool()
17
+ await pool.closeAll() // must not throw even though nothing was ever acquired
18
+ })
19
+
20
+ test('createPagePool: evict on a url that was never acquired is a safe no-op', async () => {
21
+ const pool = createPagePool()
22
+ await pool.evict('https://never-acquired.example')
23
+ })
@@ -0,0 +1,53 @@
1
+ // E3 — token-map injection + conformance snapshot.
2
+ //
3
+ // Mode B's input is an app URL + a token map (cssVar -> proposed value,
4
+ // possibly one of several viewports — the caller runs renderJob once per
5
+ // viewport). We inject the map as CSS custom properties on :root (the same
6
+ // mechanism `@sorb/leaf`'s SorbProvider uses at runtime — `var(--token,
7
+ // fallback)`), then read back what the page actually resolved so the caller
8
+ // gets a conformance snapshot, not just a rendered picture.
9
+
10
+ /**
11
+ * Set each `tokenMap` entry as a CSS custom property on `document.documentElement`.
12
+ * @param {{evaluate: Function}} page a Playwright Page (or a test double with the same shape)
13
+ * @param {Object.<string,string>} tokenMap cssVar name -> value
14
+ */
15
+ export async function injectTokenMap(page, tokenMap) {
16
+ await page.evaluate((map) => {
17
+ const root = document.documentElement
18
+ for (const key of Object.keys(map)) root.style.setProperty(key, map[key])
19
+ }, tokenMap)
20
+ }
21
+
22
+ /**
23
+ * Read back the RESOLVED (computed) value of each cssVar from `:root`.
24
+ * @param {{evaluate: Function}} page
25
+ * @param {string[]} varNames
26
+ * @returns {Promise<Object.<string,string>>} cssVar -> resolved value (raw string, trimmed)
27
+ */
28
+ export async function readResolvedVars(page, varNames) {
29
+ if (!varNames.length) return {}
30
+ return page.evaluate((names) => {
31
+ const cs = getComputedStyle(document.documentElement)
32
+ const out = {}
33
+ for (const name of names) out[name] = cs.getPropertyValue(name).trim()
34
+ return out
35
+ }, varNames)
36
+ }
37
+
38
+ /**
39
+ * Compare the requested token map against what actually resolved in-page.
40
+ * @param {Object.<string,string>} tokenMap requested cssVar -> value
41
+ * @param {Object.<string,string>} resolvedVars actual cssVar -> resolved value
42
+ * @returns {{rows: Array<{cssVar:string,expected:string,actual:string,match:boolean}>, conformant: boolean, mismatchCount: number}}
43
+ */
44
+ export function buildConformance(tokenMap, resolvedVars) {
45
+ const rows = Object.keys(tokenMap).map((cssVar) => {
46
+ const expected = String(tokenMap[cssVar]).trim()
47
+ const actualRaw = resolvedVars ? resolvedVars[cssVar] : undefined
48
+ const actual = actualRaw == null ? '' : String(actualRaw).trim()
49
+ return { cssVar, expected, actual, match: expected === actual }
50
+ })
51
+ const mismatchCount = rows.filter((r) => !r.match).length
52
+ return { rows, conformant: mismatchCount === 0, mismatchCount }
53
+ }
@@ -0,0 +1,59 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { injectTokenMap, readResolvedVars, buildConformance } from './tokenInject.js'
4
+
5
+ // ─── buildConformance (pure) ─────────────────────────────────────────────────
6
+ test('buildConformance: all match → conformant true, no mismatches', () => {
7
+ const snap = buildConformance(
8
+ { '--sorb-color-bg': '#fff', '--sorb-radius': '4px' },
9
+ { '--sorb-color-bg': '#fff', '--sorb-radius': '4px' },
10
+ )
11
+ assert.equal(snap.conformant, true)
12
+ assert.equal(snap.mismatchCount, 0)
13
+ assert.equal(snap.rows.length, 2)
14
+ assert.ok(snap.rows.every((r) => r.match))
15
+ })
16
+
17
+ test('buildConformance: a resolved value differing from requested is a mismatch', () => {
18
+ const snap = buildConformance({ '--x': '4px' }, { '--x': '8px' })
19
+ assert.equal(snap.conformant, false)
20
+ assert.equal(snap.mismatchCount, 1)
21
+ assert.deepEqual(snap.rows[0], { cssVar: '--x', expected: '4px', actual: '8px', match: false })
22
+ })
23
+
24
+ test('buildConformance: a missing resolved var counts as a mismatch (actual = "")', () => {
25
+ const snap = buildConformance({ '--x': '4px' }, {})
26
+ assert.equal(snap.mismatchCount, 1)
27
+ assert.equal(snap.rows[0].actual, '')
28
+ })
29
+
30
+ test('buildConformance: whitespace is trimmed before comparing', () => {
31
+ const snap = buildConformance({ '--x': ' 4px ' }, { '--x': '4px' })
32
+ assert.equal(snap.rows[0].match, true)
33
+ })
34
+
35
+ // ─── injectTokenMap / readResolvedVars (thin page.evaluate wrappers) ────────
36
+ test('injectTokenMap: calls page.evaluate once with the token map as the arg', async () => {
37
+ const calls = []
38
+ const page = { evaluate: async (fn, arg) => calls.push({ fn, arg }) }
39
+ await injectTokenMap(page, { '--x': '1px' })
40
+ assert.equal(calls.length, 1)
41
+ assert.equal(typeof calls[0].fn, 'function')
42
+ assert.deepEqual(calls[0].arg, { '--x': '1px' })
43
+ })
44
+
45
+ test('readResolvedVars: passes the var names through and returns page.evaluate result', async () => {
46
+ const page = {
47
+ evaluate: async (fn, names) => Object.fromEntries(names.map((n) => [n, 'resolved:' + n])),
48
+ }
49
+ const out = await readResolvedVars(page, ['--a', '--b'])
50
+ assert.deepEqual(out, { '--a': 'resolved:--a', '--b': 'resolved:--b' })
51
+ })
52
+
53
+ test('readResolvedVars: short-circuits to {} for an empty var list (no page.evaluate call)', async () => {
54
+ let called = false
55
+ const page = { evaluate: async () => { called = true } }
56
+ const out = await readResolvedVars(page, [])
57
+ assert.deepEqual(out, {})
58
+ assert.equal(called, false)
59
+ })
@@ -0,0 +1,24 @@
1
+ // Bundles the SAME in-page walker `capture.js` uses (captureRoot →
2
+ // `window.__sorbCapture`) into a single IIFE string, exactly like
3
+ // captureCli.js's private `buildWalkerBundle` and the cloud's
4
+ // runnerEntry.mjs. Factored out here so the render worker can inject it into
5
+ // an arbitrary app page (not just a Storybook iframe) without duplicating the
6
+ // walker itself — only the bundling call is repeated, the capture ALGORITHM
7
+ // (capture.js) is the single shared source.
8
+ import { build } from 'esbuild'
9
+ import { dirname, resolve } from 'node:path'
10
+
11
+ /** @returns {Promise<string>} an IIFE that installs `window.__sorbCapture`. */
12
+ export const buildWalkerBundle = async () => {
13
+ const here = dirname(new URL(import.meta.url).pathname)
14
+ const out = await build({
15
+ entryPoints: [resolve(here, '../capture.js')],
16
+ bundle: true,
17
+ format: 'iife',
18
+ platform: 'browser',
19
+ write: false,
20
+ logLevel: 'silent',
21
+ target: 'es2020',
22
+ })
23
+ return out.outputFiles[0].text
24
+ }
@@ -0,0 +1,12 @@
1
+ // esbuild-only (no Playwright/browser needed) — proves the render worker
2
+ // bundles the SAME walker capture.js/captureCli.js already use.
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { buildWalkerBundle } from './walkerBundle.js'
6
+
7
+ test('buildWalkerBundle: produces an IIFE that installs window.__sorbCapture', async () => {
8
+ const bundle = await buildWalkerBundle()
9
+ assert.equal(typeof bundle, 'string')
10
+ assert.ok(bundle.includes('__sorbCapture'))
11
+ assert.ok(bundle.length > 0)
12
+ })
@@ -0,0 +1,193 @@
1
+ // E3 — hosted-capture render worker (Mode B).
2
+ // hosted-bridge-modes-exploration-plan.md §3 E3.
3
+ //
4
+ // Wraps the EXISTING sorb-seed Playwright capture machinery as a single-job,
5
+ // on-demand render: given an app URL + a token map, navigate, inject the
6
+ // tokens as CSS custom properties, and capture a screenshot + the rendered DOM
7
+ // (via the SAME walker `capture.js`/`captureCli.js` already use) + a
8
+ // conformance snapshot. This is deliberately generic-URL, not Storybook-only
9
+ // (Mode B's reachable target class per E0 is public Storybook/DS-docs/staging
10
+ // URLs, but the walker itself works against any rendered page — Storybook is
11
+ // just where E0 found "public and reachable" apps living).
12
+ //
13
+ // Target class / auth wall (per E0, do not build here): only public URLs are
14
+ // supported. There is no credential-handling path — an auth-walled URL will
15
+ // simply fail to render the real app (login page instead), which is the
16
+ // documented, deferred follow-up, not a bug to fix in this phase.
17
+ //
18
+ // Playwright stays OPTIONAL and LAZY: this module never imports `playwright`
19
+ // itself. It only reaches Playwright through `pagePool.js`'s `createPagePool`,
20
+ // which is dynamically imported on first use — so `renderJob` (and this whole
21
+ // module) import cleanly with Playwright absent, and only throw (with the same
22
+ // actionable message as `captureCli.js`) once a render actually happens.
23
+
24
+ import { writeFile } from 'node:fs/promises'
25
+ import { randomUUID } from 'node:crypto'
26
+ import { resolve } from 'node:path'
27
+ import { tmpdir } from 'node:os'
28
+
29
+ import { injectTokenMap, readResolvedVars, buildConformance } from './tokenInject.js'
30
+ import { DiffCache, makeCacheKey } from './diffCache.js'
31
+
32
+ /**
33
+ * @typedef {Object.<string,string>} TokenMap cssVar -> value
34
+ *
35
+ * @typedef {object} RenderJobInput
36
+ * @property {string} url app URL to render (public/staging — see module doc)
37
+ * @property {TokenMap} [tokenMap] cssVars to inject as :root custom properties
38
+ * @property {{width:number,height:number}} [viewport] default 1280x800; call
39
+ * once per viewport for multi-viewport token maps
40
+ * @property {string} [selector] root selector to capture (default 'body')
41
+ * @property {boolean} [diffOnly] enable the diff-cache spike (default true)
42
+ * @property {string} [screenshotDir] dir to write the PNG into (default os.tmpdir())
43
+ * @property {string} [screenshotPath] exact PNG path (overrides screenshotDir)
44
+ *
45
+ * @typedef {object} RenderJobDeps injectable seams (tests supply fakes)
46
+ * @property {() => Promise<import('./pagePool.js').PagePool>} [pagePool]
47
+ * @property {DiffCache} [cache]
48
+ * @property {(page: object, selector: string) => Promise<object>} [captureFn]
49
+ * @property {() => number} [now]
50
+ *
51
+ * @typedef {{path:string, width:number, height:number}} ScreenshotRef
52
+ * A REFERENCE to the written screenshot file — never the image bytes inline
53
+ * (so logs/NDJSON stay small; callers read the file themselves).
54
+ *
55
+ * @typedef {object} ConformanceRow
56
+ * @property {string} cssVar
57
+ * @property {string} expected
58
+ * @property {string} actual
59
+ * @property {boolean} match
60
+ *
61
+ * @typedef {object} ConformanceSnapshot
62
+ * @property {ConformanceRow[]} rows
63
+ * @property {boolean} conformant
64
+ * @property {number} mismatchCount
65
+ *
66
+ * @typedef {object} RenderJobTimings ms, for cloud throttle/telemetry metering
67
+ * @property {number} navigateMs
68
+ * @property {number} injectMs
69
+ * @property {number} captureMs
70
+ * @property {number} screenshotMs
71
+ * @property {number} totalMs
72
+ *
73
+ * @typedef {object} RenderJobDiff node-path diff vs this URL's last render
74
+ * @property {string[]} changed
75
+ * @property {string[]} added
76
+ * @property {string[]} removed
77
+ *
78
+ * @typedef {object} RenderJobResult
79
+ * @property {ScreenshotRef} screenshot
80
+ * @property {object} dom captured LayerNode tree (root)
81
+ * @property {ConformanceSnapshot} conformance
82
+ * @property {RenderJobTimings} timings
83
+ * @property {boolean} cacheHit exact (url,tokenMap) match — render skipped entirely
84
+ * @property {boolean} pageReused navigation skipped (page already open for this url)
85
+ * @property {RenderJobDiff|null} diff null if diffOnly=false or first render for the url
86
+ * @property {string} cacheKey
87
+ */
88
+
89
+ let sharedPoolPromise = null
90
+ async function defaultPagePool() {
91
+ if (!sharedPoolPromise) {
92
+ sharedPoolPromise = import('./pagePool.js').then((m) => m.createPagePool())
93
+ }
94
+ return sharedPoolPromise
95
+ }
96
+
97
+ const defaultCache = new DiffCache()
98
+
99
+ let sharedWalkerBundle = null
100
+ async function defaultCaptureFn(page, selector) {
101
+ const { buildWalkerBundle } = await import('./walkerBundle.js')
102
+ if (!sharedWalkerBundle) sharedWalkerBundle = await buildWalkerBundle()
103
+ // addInitScript covers future navigations on this page; evaluating the
104
+ // bundle directly also installs it on the CURRENT document — required on
105
+ // the page-reuse path, where no new navigation happens.
106
+ await page.addInitScript({ content: sharedWalkerBundle })
107
+ await page.evaluate(sharedWalkerBundle)
108
+ return page.evaluate((sel) => {
109
+ const root = document.querySelector(sel) || document.body
110
+ return window.__sorbCapture(root)
111
+ }, selector)
112
+ }
113
+
114
+ /**
115
+ * Render one job: navigate (or reuse) `url`, inject `tokenMap`, capture a
116
+ * screenshot + DOM + conformance snapshot. Reuses sorb-seed's existing
117
+ * Playwright capture machinery — this function does not reimplement it.
118
+ * @param {RenderJobInput} input
119
+ * @param {RenderJobDeps} [deps]
120
+ * @returns {Promise<RenderJobResult>}
121
+ */
122
+ export async function renderJob(input, deps = {}) {
123
+ if (!input || !input.url) throw new Error('renderJob: input.url is required')
124
+ const {
125
+ url,
126
+ tokenMap = {},
127
+ viewport = { width: 1280, height: 800 },
128
+ selector = 'body',
129
+ diffOnly = true,
130
+ screenshotDir = tmpdir(),
131
+ screenshotPath,
132
+ } = input
133
+
134
+ const cache = deps.cache || defaultCache
135
+ const now = deps.now || (() => Date.now())
136
+ const t0 = now()
137
+
138
+ // 1. Exact-match short-circuit — same url + byte-identical token map.
139
+ const cacheKey = makeCacheKey(url, tokenMap)
140
+ const cached = cache.getExact(url, tokenMap)
141
+ if (cached) {
142
+ return { ...cached, cacheHit: true, timings: { ...cached.timings, totalMs: now() - t0 } }
143
+ }
144
+
145
+ // 2. Acquire a page — reused (nav skipped) if we already rendered this url.
146
+ const getPool = deps.pagePool || defaultPagePool
147
+ const pool = await getPool()
148
+ const tNav = now()
149
+ const { page, reused } = await pool.acquire(url, viewport)
150
+ const navigateMs = now() - tNav
151
+
152
+ // 3. Inject the token map.
153
+ const tInject = now()
154
+ await injectTokenMap(page, tokenMap)
155
+ const injectMs = now() - tInject
156
+
157
+ // 4. Capture DOM (reused walker) + conformance.
158
+ const tCapture = now()
159
+ const captureFn = deps.captureFn || defaultCaptureFn
160
+ const dom = await captureFn(page, selector)
161
+ const resolvedVars = await readResolvedVars(page, Object.keys(tokenMap))
162
+ const conformance = buildConformance(tokenMap, resolvedVars)
163
+ const captureMs = now() - tCapture
164
+
165
+ // 5. Screenshot → file ref (never returned inline).
166
+ const tShot = now()
167
+ const path = screenshotPath || resolve(screenshotDir, `sorb-render-${randomUUID()}.png`)
168
+ const shot = await page.screenshot()
169
+ await writeFile(path, shot)
170
+ const screenshotMs = now() - tShot
171
+
172
+ const timings = { navigateMs, injectMs, captureMs, screenshotMs, totalMs: now() - t0 }
173
+ const diff = diffOnly ? cache.diffAgainstLastForUrl(url, tokenMap, dom) : null
174
+
175
+ const result = {
176
+ screenshot: { path, width: viewport.width, height: viewport.height },
177
+ dom,
178
+ conformance,
179
+ timings,
180
+ cacheHit: false,
181
+ pageReused: reused,
182
+ diff,
183
+ cacheKey,
184
+ }
185
+
186
+ cache.putExact(url, tokenMap, result)
187
+ return result
188
+ }
189
+
190
+ /** Reset the module-level default cache (test helper). */
191
+ export function __resetDefaultCache() {
192
+ defaultCache.clear()
193
+ }
@@ -0,0 +1,233 @@
1
+ // Unit tests for the E3 render-worker orchestration. NO real Playwright/browser
2
+ // is used — `page`/`pool`/`cache` are all fakes injected via `deps`, so these
3
+ // tests exercise job orchestration, token-injection wiring, the diff-cache, and
4
+ // the result shape without a live browser (per the task's hard rule).
5
+
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFile, rm } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { randomUUID } from 'node:crypto'
12
+
13
+ import { renderJob } from './worker.js'
14
+ import { DiffCache } from './diffCache.js'
15
+
16
+ // ─── fakes ────────────────────────────────────────────────────────────────────
17
+ function makeFakePage({ resolvedVars = {} } = {}) {
18
+ const calls = { evaluate: 0, screenshot: 0 }
19
+ return {
20
+ calls,
21
+ async evaluate() {
22
+ calls.evaluate++
23
+ // 1st call in renderJob is injectTokenMap (no meaningful return needed);
24
+ // 2nd is readResolvedVars, which needs the resolved-values shape back.
25
+ if (calls.evaluate === 1) return undefined
26
+ return resolvedVars
27
+ },
28
+ async screenshot() {
29
+ calls.screenshot++
30
+ return Buffer.from('fake-png-bytes')
31
+ },
32
+ async addInitScript() {},
33
+ async setViewportSize() {},
34
+ }
35
+ }
36
+
37
+ function makeFakePool(pagesByUrl = {}) {
38
+ const acquireCalls = []
39
+ const seen = new Set()
40
+ return {
41
+ acquireCalls,
42
+ async acquire(url, viewport) {
43
+ acquireCalls.push({ url, viewport })
44
+ const reused = seen.has(url)
45
+ seen.add(url)
46
+ const page = pagesByUrl[url] || (pagesByUrl[url] = makeFakePage())
47
+ return { page, reused }
48
+ },
49
+ async evict() {},
50
+ async closeAll() {},
51
+ }
52
+ }
53
+
54
+ const scratchDir = tmpdir()
55
+ const scratchPath = () => join(scratchDir, `sorb-worker-test-${randomUUID()}.png`)
56
+
57
+ // ─── basic shape + reuse ──────────────────────────────────────────────────────
58
+ test('renderJob: requires input.url', async () => {
59
+ await assert.rejects(() => renderJob({}), /url is required/)
60
+ })
61
+
62
+ test('renderJob: returns the documented result shape and writes the screenshot file', async () => {
63
+ const pool = makeFakePool()
64
+ const cache = new DiffCache()
65
+ const path = scratchPath()
66
+ let captureCalls = 0
67
+ const captureFn = async () => {
68
+ captureCalls++
69
+ return { type: 'FRAME', name: 'body', fills: [], children: [] }
70
+ }
71
+
72
+ const result = await renderJob(
73
+ {
74
+ url: 'https://demo.sorbcloud.com',
75
+ tokenMap: { '--sorb-color-bg': '#fff' },
76
+ screenshotPath: path,
77
+ },
78
+ { pagePool: async () => pool, cache, captureFn },
79
+ )
80
+
81
+ try {
82
+ assert.equal(result.cacheHit, false)
83
+ assert.equal(result.pageReused, false)
84
+ assert.equal(captureCalls, 1)
85
+ assert.equal(result.screenshot.path, path)
86
+ assert.equal(result.screenshot.width, 1280)
87
+ assert.equal(result.screenshot.height, 800)
88
+ assert.ok(result.dom)
89
+ assert.ok(result.conformance)
90
+ assert.equal(typeof result.conformance.conformant, 'boolean')
91
+ assert.ok(result.timings)
92
+ for (const k of ['navigateMs', 'injectMs', 'captureMs', 'screenshotMs', 'totalMs']) {
93
+ assert.equal(typeof result.timings[k], 'number')
94
+ }
95
+ assert.equal(result.diff, null) // first render for this url → no prior to diff against
96
+ assert.ok(result.cacheKey.includes('https://demo.sorbcloud.com'))
97
+
98
+ const written = await readFile(path)
99
+ assert.equal(written.toString(), 'fake-png-bytes')
100
+ } finally {
101
+ await rm(path, { force: true })
102
+ }
103
+ })
104
+
105
+ // ─── exact-match cache short-circuit ─────────────────────────────────────────
106
+ test('renderJob: identical (url, tokenMap) on a warm cache short-circuits the render', async () => {
107
+ const pool = makeFakePool()
108
+ const cache = new DiffCache()
109
+ let captureCalls = 0
110
+ const captureFn = async () => {
111
+ captureCalls++
112
+ return { type: 'FRAME', children: [] }
113
+ }
114
+ const input = {
115
+ url: 'https://demo.sorbcloud.com',
116
+ tokenMap: { '--x': '1px' },
117
+ screenshotPath: scratchPath(),
118
+ }
119
+ const deps = { pagePool: async () => pool, cache, captureFn }
120
+
121
+ const r1 = await renderJob(input, deps)
122
+ const r2 = await renderJob({ ...input, screenshotPath: scratchPath() }, deps)
123
+
124
+ assert.equal(r1.cacheHit, false)
125
+ assert.equal(r2.cacheHit, true)
126
+ assert.equal(captureCalls, 1) // second call never re-rendered
127
+ assert.equal(r2.screenshot.path, r1.screenshot.path) // cached result's own screenshot ref
128
+ await rm(r1.screenshot.path, { force: true })
129
+ })
130
+
131
+ test('renderJob: different tokenMap for the same url is NOT a cache hit', async () => {
132
+ const pool = makeFakePool()
133
+ const cache = new DiffCache()
134
+ let captureCalls = 0
135
+ const captureFn = async () => {
136
+ captureCalls++
137
+ return { type: 'FRAME', children: [] }
138
+ }
139
+ const deps = { pagePool: async () => pool, cache, captureFn }
140
+
141
+ const p1 = scratchPath()
142
+ const p2 = scratchPath()
143
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1px' }, screenshotPath: p1 }, deps)
144
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2px' }, screenshotPath: p2 }, deps)
145
+
146
+ assert.equal(r1.cacheHit, false)
147
+ assert.equal(r2.cacheHit, false)
148
+ assert.equal(captureCalls, 2)
149
+ await rm(p1, { force: true })
150
+ await rm(p2, { force: true })
151
+ })
152
+
153
+ // ─── page reuse (navigation-skip) ────────────────────────────────────────────
154
+ test('renderJob: a second render of the same url reuses the page (pageReused=true)', async () => {
155
+ const pool = makeFakePool()
156
+ const cache = new DiffCache()
157
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
158
+ const deps = { pagePool: async () => pool, cache, captureFn }
159
+
160
+ const p1 = scratchPath()
161
+ const p2 = scratchPath()
162
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1px' }, screenshotPath: p1 }, deps)
163
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2px' }, screenshotPath: p2 }, deps)
164
+
165
+ assert.equal(r1.pageReused, false)
166
+ assert.equal(r2.pageReused, true)
167
+ assert.equal(pool.acquireCalls.length, 2)
168
+ await rm(p1, { force: true })
169
+ await rm(p2, { force: true })
170
+ })
171
+
172
+ // ─── diff-cache reporting ─────────────────────────────────────────────────────
173
+ test('renderJob: diffOnly=true reports which dom subtree changed on a re-render', async () => {
174
+ const pool = makeFakePool()
175
+ const cache = new DiffCache()
176
+ let call = 0
177
+ const captureFn = async () => {
178
+ call++
179
+ // second capture differs from the first (a changed fill on the same shape)
180
+ return {
181
+ type: 'FRAME',
182
+ children: [{ type: 'FRAME', fills: call === 1 ? [] : [{ raw: '#fff' }], children: [] }],
183
+ }
184
+ }
185
+ const deps = { pagePool: async () => pool, cache, captureFn }
186
+
187
+ const p1 = scratchPath()
188
+ const p2 = scratchPath()
189
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1' }, screenshotPath: p1, diffOnly: true }, deps)
190
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2' }, screenshotPath: p2, diffOnly: true }, deps)
191
+
192
+ assert.equal(r1.diff, null)
193
+ assert.ok(r2.diff)
194
+ assert.ok(r2.diff.changed.length > 0)
195
+ await rm(p1, { force: true })
196
+ await rm(p2, { force: true })
197
+ })
198
+
199
+ test('renderJob: diffOnly=false skips the diff computation entirely', async () => {
200
+ const pool = makeFakePool()
201
+ const cache = new DiffCache()
202
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
203
+ const deps = { pagePool: async () => pool, cache, captureFn }
204
+
205
+ const p1 = scratchPath()
206
+ const p2 = scratchPath()
207
+ await renderJob({ url: 'https://a', tokenMap: { '--x': '1' }, screenshotPath: p1, diffOnly: false }, deps)
208
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2' }, screenshotPath: p2, diffOnly: false }, deps)
209
+
210
+ assert.equal(r2.diff, null)
211
+ await rm(p1, { force: true })
212
+ await rm(p2, { force: true })
213
+ })
214
+
215
+ // ─── conformance wiring end-to-end (with the captureFn/page fakes) ──────────
216
+ test('renderJob: conformance reflects the resolved vs requested token values', async () => {
217
+ const page = makeFakePage({ resolvedVars: { '--sorb-color-bg': '#000' } }) // mismatch on purpose
218
+ const pool = { async acquire() { return { page, reused: false } }, async evict() {}, async closeAll() {} }
219
+ const cache = new DiffCache()
220
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
221
+ const path = scratchPath()
222
+
223
+ const result = await renderJob(
224
+ { url: 'https://a', tokenMap: { '--sorb-color-bg': '#fff' }, screenshotPath: path },
225
+ { pagePool: async () => pool, cache, captureFn },
226
+ )
227
+
228
+ assert.equal(result.conformance.conformant, false)
229
+ assert.equal(result.conformance.mismatchCount, 1)
230
+ assert.equal(result.conformance.rows[0].expected, '#fff')
231
+ assert.equal(result.conformance.rows[0].actual, '#000')
232
+ await rm(path, { force: true })
233
+ })