@sorb/seed 0.1.1 → 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/README.md +15 -5
- package/package.json +6 -3
- package/src/captureCli.js +22 -1
- 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,110 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
stableStringify,
|
|
5
|
+
hashTokenMap,
|
|
6
|
+
makeCacheKey,
|
|
7
|
+
hashNodePaths,
|
|
8
|
+
diffNodeHashes,
|
|
9
|
+
DiffCache,
|
|
10
|
+
} from './diffCache.js'
|
|
11
|
+
|
|
12
|
+
test('stableStringify: key order does not affect the result', () => {
|
|
13
|
+
const a = stableStringify({ b: 1, a: 2 })
|
|
14
|
+
const b = stableStringify({ a: 2, b: 1 })
|
|
15
|
+
assert.equal(a, b)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('hashTokenMap: order-independent, sensitive to values', () => {
|
|
19
|
+
const h1 = hashTokenMap({ '--x': '1', '--y': '2' })
|
|
20
|
+
const h2 = hashTokenMap({ '--y': '2', '--x': '1' })
|
|
21
|
+
const h3 = hashTokenMap({ '--y': '3', '--x': '1' })
|
|
22
|
+
assert.equal(h1, h2)
|
|
23
|
+
assert.notEqual(h1, h3)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('hashTokenMap: empty/undefined map is stable', () => {
|
|
27
|
+
assert.equal(hashTokenMap(undefined), hashTokenMap({}))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('makeCacheKey: combines url + token-map hash', () => {
|
|
31
|
+
const k1 = makeCacheKey('https://a.example', { '--x': '1' })
|
|
32
|
+
const k2 = makeCacheKey('https://b.example', { '--x': '1' })
|
|
33
|
+
const k3 = makeCacheKey('https://a.example', { '--x': '2' })
|
|
34
|
+
assert.notEqual(k1, k2)
|
|
35
|
+
assert.notEqual(k1, k3)
|
|
36
|
+
assert.equal(k1, makeCacheKey('https://a.example', { '--x': '1' }))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ─── node-path hashing / diffing ─────────────────────────────────────────────
|
|
40
|
+
const tree = (fill, children = []) => ({ type: 'FRAME', fills: fill ? [{ raw: fill }] : [], children })
|
|
41
|
+
|
|
42
|
+
test('hashNodePaths: identical trees produce identical hashes at every path', () => {
|
|
43
|
+
const t1 = tree('#fff', [tree('#000'), tree('#111')])
|
|
44
|
+
const t2 = tree('#fff', [tree('#000'), tree('#111')])
|
|
45
|
+
const h1 = hashNodePaths(t1)
|
|
46
|
+
const h2 = hashNodePaths(t2)
|
|
47
|
+
assert.deepEqual([...h1.keys()].sort(), [...h2.keys()].sort())
|
|
48
|
+
for (const [path, hash] of h1) assert.equal(hash, h2.get(path))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('diffNodeHashes: a leaf change bubbles up to its ancestors, siblings unaffected', () => {
|
|
52
|
+
const before = tree('#fff', [tree('#000'), tree('#111')])
|
|
53
|
+
const after = tree('#fff', [tree('#000'), tree('#222')]) // child 1 changed
|
|
54
|
+
const diff = diffNodeHashes(hashNodePaths(before), hashNodePaths(after))
|
|
55
|
+
// root (path "0") and the changed child ("0.1") both bubble; the unchanged
|
|
56
|
+
// sibling ("0.0") must NOT appear.
|
|
57
|
+
assert.ok(diff.changed.includes('0'))
|
|
58
|
+
assert.ok(diff.changed.includes('0.1'))
|
|
59
|
+
assert.ok(!diff.changed.includes('0.0'))
|
|
60
|
+
assert.deepEqual(diff.added, [])
|
|
61
|
+
assert.deepEqual(diff.removed, [])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('diffNodeHashes: added/removed children are reported', () => {
|
|
65
|
+
const before = tree('#fff', [tree('#000')])
|
|
66
|
+
const after = tree('#fff', [tree('#000'), tree('#111')])
|
|
67
|
+
const diff = diffNodeHashes(hashNodePaths(before), hashNodePaths(after))
|
|
68
|
+
assert.ok(diff.added.includes('0.1'))
|
|
69
|
+
assert.ok(diff.changed.includes('0')) // root's own hash bubbled (child count changed)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ─── DiffCache ────────────────────────────────────────────────────────────────
|
|
73
|
+
test('DiffCache: exact getExact/putExact round-trips on (url, tokenMap)', () => {
|
|
74
|
+
const cache = new DiffCache()
|
|
75
|
+
assert.equal(cache.getExact('https://a', { x: '1' }), undefined)
|
|
76
|
+
cache.putExact('https://a', { x: '1' }, { some: 'result' })
|
|
77
|
+
assert.deepEqual(cache.getExact('https://a', { x: '1' }), { some: 'result' })
|
|
78
|
+
// different token map → different key → no hit
|
|
79
|
+
assert.equal(cache.getExact('https://a', { x: '2' }), undefined)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('DiffCache: diffAgainstLastForUrl returns null on first render, a diff on the second', () => {
|
|
83
|
+
const cache = new DiffCache()
|
|
84
|
+
const first = tree('#fff', [tree('#000')])
|
|
85
|
+
const d1 = cache.diffAgainstLastForUrl('https://a', { x: '1' }, first)
|
|
86
|
+
assert.equal(d1, null)
|
|
87
|
+
|
|
88
|
+
const second = tree('#fff', [tree('#111')]) // changed
|
|
89
|
+
const d2 = cache.diffAgainstLastForUrl('https://a', { x: '2' }, second)
|
|
90
|
+
assert.ok(d2)
|
|
91
|
+
assert.ok(d2.changed.length > 0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('DiffCache: capacity eviction bounds memory (oldest-first)', () => {
|
|
95
|
+
const cache = new DiffCache({ capacity: 2 })
|
|
96
|
+
cache.putExact('https://a', {}, { n: 1 })
|
|
97
|
+
cache.putExact('https://b', {}, { n: 2 })
|
|
98
|
+
cache.putExact('https://c', {}, { n: 3 }) // evicts https://a
|
|
99
|
+
assert.equal(cache.getExact('https://a', {}), undefined)
|
|
100
|
+
assert.deepEqual(cache.getExact('https://c', {}), { n: 3 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('DiffCache: clear() wipes both maps', () => {
|
|
104
|
+
const cache = new DiffCache()
|
|
105
|
+
cache.putExact('https://a', {}, { n: 1 })
|
|
106
|
+
cache.diffAgainstLastForUrl('https://a', {}, tree('#fff'))
|
|
107
|
+
cache.clear()
|
|
108
|
+
assert.equal(cache.getExact('https://a', {}), undefined)
|
|
109
|
+
assert.equal(cache.diffAgainstLastForUrl('https://a', {}, tree('#fff')), null)
|
|
110
|
+
})
|
|
@@ -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
|
+
}
|