@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.
- package/LICENSE +201 -0
- package/README.md +15 -5
- package/package.json +6 -3
- package/src/annotateTokens.js +161 -19
- package/src/annotateTokens.test.js +208 -0
- package/src/capture.js +100 -0
- package/src/capture.test.js +142 -0
- package/src/captureCli.js +29 -2
- package/src/cli.js +185 -3
- package/src/cli.test.js +51 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
|
@@ -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
|
+
})
|