@sorb/seed 0.1.1 → 0.3.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.
Files changed (45) hide show
  1. package/README.md +15 -5
  2. package/package.json +10 -3
  3. package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
  4. package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
  5. package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
  6. package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
  7. package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
  8. package/src/adapt/adaptCli.js +110 -0
  9. package/src/adapt/adaptCli.test.js +109 -0
  10. package/src/adapt/benchmark.js +113 -0
  11. package/src/adapt/benchmark.test.js +79 -0
  12. package/src/adapt/codemod.js +160 -0
  13. package/src/adapt/codemod.test.js +120 -0
  14. package/src/adapt/detectHardcoded.js +165 -0
  15. package/src/adapt/detectHardcoded.test.js +62 -0
  16. package/src/adapt/glob.js +87 -0
  17. package/src/adapt/mapToToken.js +94 -0
  18. package/src/adapt/mapToToken.test.js +103 -0
  19. package/src/adapt/report.js +53 -0
  20. package/src/adapt/report.test.js +66 -0
  21. package/src/adapt/runBenchmark.js +16 -0
  22. package/src/adapt/types.js +48 -0
  23. package/src/captureCli.js +38 -118
  24. package/src/cli.js +200 -3
  25. package/src/cli.test.js +51 -0
  26. package/src/emit/sorbFormat.js +313 -0
  27. package/src/emit/sorbFormat.test.js +133 -0
  28. package/src/index.js +27 -0
  29. package/src/render/cli.js +82 -0
  30. package/src/render/cli.test.js +47 -0
  31. package/src/render/diffCache.js +152 -0
  32. package/src/render/diffCache.test.js +110 -0
  33. package/src/render/pagePool.js +82 -0
  34. package/src/render/pagePool.test.js +23 -0
  35. package/src/render/tokenInject.js +53 -0
  36. package/src/render/tokenInject.test.js +59 -0
  37. package/src/render/walkerBundle.js +24 -0
  38. package/src/render/walkerBundle.test.js +12 -0
  39. package/src/render/worker.js +193 -0
  40. package/src/render/worker.test.js +233 -0
  41. package/src/sources/figmaPlugin.js +83 -0
  42. package/src/sources/figmaPlugin.test.js +65 -0
  43. package/src/sources/storybookDom.js +188 -0
  44. package/src/variants.js +279 -0
  45. package/src/variants.test.js +129 -0
@@ -0,0 +1,133 @@
1
+ // Tests for the Sorb Tailwind v4 theme format (`sorb/tailwind-theme`).
2
+ // Run: node --test (zero-dep, Node's built-in runner — matches the workspace convention).
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { tailwindThemeEntry, sorbTailwind, tailwindV3Slot, sorbTailwindV3 } from './sorbFormat.js'
6
+
7
+ // Helper: a token as Style Dictionary hands it to a format (path + $type).
8
+ const tok = (id, type) => ({ path: id.split('.'), $type: type })
9
+
10
+ test('color tokens map to the Tailwind --color-* family, value = var(self)', () => {
11
+ // semantic: leading `color.` is the namespace, not doubled
12
+ assert.deepEqual(tailwindThemeEntry(tok('color.action.primary', 'color')), {
13
+ key: '--color-action-primary',
14
+ ref: 'var(--color-action-primary)',
15
+ })
16
+ // primitive ramp
17
+ assert.deepEqual(tailwindThemeEntry(tok('color.blue.300', 'color')), {
18
+ key: '--color-blue-300',
19
+ ref: 'var(--color-blue-300)',
20
+ })
21
+ })
22
+
23
+ test('component colors keep their full path under the color family', () => {
24
+ // button.primary.bg.default has $type color but path does NOT start with `color`
25
+ assert.deepEqual(tailwindThemeEntry(tok('button.primary.bg.default', 'color')), {
26
+ key: '--color-button-primary-bg-default',
27
+ ref: 'var(--button-primary-bg-default)', // ref always points at the token's own css var
28
+ })
29
+ })
30
+
31
+ test('radius dimensions map to --radius-* (→ rounded-* utilities)', () => {
32
+ assert.deepEqual(tailwindThemeEntry(tok('radius.100', 'dimension')), {
33
+ key: '--radius-100',
34
+ ref: 'var(--radius-100)',
35
+ })
36
+ // component radius is remapped into the radius family so rounded-button works
37
+ assert.deepEqual(tailwindThemeEntry(tok('button.radius', 'dimension')), {
38
+ key: '--radius-button',
39
+ ref: 'var(--button-radius)',
40
+ })
41
+ })
42
+
43
+ test('space → --spacing-*, font.size → --text-*, fontWeight → --font-weight-*', () => {
44
+ assert.equal(tailwindThemeEntry(tok('space.200', 'dimension')).key, '--spacing-200')
45
+ assert.equal(tailwindThemeEntry(tok('space.200', 'dimension')).ref, 'var(--space-200)')
46
+ assert.equal(tailwindThemeEntry(tok('font.size.300', 'dimension')).key, '--text-300')
47
+ assert.equal(tailwindThemeEntry(tok('font.weight.semibold', 'fontWeight')).key, '--font-weight-semibold')
48
+ })
49
+
50
+ test('the ref is ALWAYS a var() reference, never a baked literal (live-preview invariant)', () => {
51
+ // This is the property that makes the bridge's runtime var-swap recolor
52
+ // Tailwind utilities with no Tailwind-specific code. Every entry must be var(…).
53
+ for (const t of [
54
+ tok('color.action.primary', 'color'),
55
+ tok('button.radius', 'dimension'),
56
+ tok('space.100', 'dimension'),
57
+ ]) {
58
+ assert.match(tailwindThemeEntry(t).ref, /^var\(--[a-z0-9-]+\)$/)
59
+ }
60
+ })
61
+
62
+ test('sorbTailwind emits an `@theme inline` block, one entry per token', () => {
63
+ const dictionary = {
64
+ allTokens: [
65
+ tok('color.action.primary', 'color'),
66
+ tok('button.radius', 'dimension'),
67
+ tok('space.100', 'dimension'),
68
+ ],
69
+ }
70
+ const css = sorbTailwind({ dictionary })
71
+ assert.match(css, /@theme inline \{/)
72
+ assert.match(css, /\}\s*$/)
73
+ assert.match(css, /^ {2}--color-action-primary: var\(--color-action-primary\);$/m)
74
+ assert.match(css, /^ {2}--radius-button: var\(--button-radius\);$/m)
75
+ // exactly 3 entries
76
+ assert.equal((css.match(/: var\(/g) || []).length, 3)
77
+ })
78
+
79
+ test('duplicate theme keys are skipped (collision guard), not emitted twice', () => {
80
+ const dictionary = {
81
+ // two distinct tokens that would collapse to the same theme key
82
+ allTokens: [tok('color.action.primary', 'color'), tok('color.action.primary', 'color')],
83
+ }
84
+ const css = sorbTailwind({ dictionary })
85
+ assert.equal((css.match(/--color-action-primary: /g) || []).length, 1)
86
+ })
87
+
88
+ // ─── Tailwind v3 preset (sorb/tailwind-v3-preset) ────────────────────────────
89
+
90
+ test('v3 slot: colors strip leading `color`, components keep full path', () => {
91
+ assert.deepEqual(tailwindV3Slot(tok('color.action.primary', 'color')), {
92
+ category: 'colors', keyPath: ['action', 'primary'], ref: 'var(--color-action-primary)',
93
+ })
94
+ assert.deepEqual(tailwindV3Slot(tok('button.primary.bg.default', 'color')), {
95
+ category: 'colors', keyPath: ['button', 'primary', 'bg', 'default'], ref: 'var(--button-primary-bg-default)',
96
+ })
97
+ })
98
+
99
+ test('v3 slot: dimensions/weights map to the right v3 categories', () => {
100
+ assert.deepEqual(tailwindV3Slot(tok('radius.100', 'dimension')), { category: 'borderRadius', keyPath: ['100'], ref: 'var(--radius-100)' })
101
+ assert.deepEqual(tailwindV3Slot(tok('button.radius', 'dimension')), { category: 'borderRadius', keyPath: ['button'], ref: 'var(--button-radius)' })
102
+ assert.deepEqual(tailwindV3Slot(tok('space.200', 'dimension')), { category: 'spacing', keyPath: ['200'], ref: 'var(--space-200)' })
103
+ assert.deepEqual(tailwindV3Slot(tok('font.size.300', 'dimension')), { category: 'fontSize', keyPath: ['300'], ref: 'var(--font-size-300)' })
104
+ assert.deepEqual(tailwindV3Slot(tok('font.weight.regular', 'fontWeight')), { category: 'fontWeight', keyPath: ['regular'], ref: 'var(--font-weight-regular)' })
105
+ })
106
+
107
+ test('v3 slot: unknown type → null (no v3 family)', () => {
108
+ assert.equal(tailwindV3Slot({ path: ['z', 'index'], $type: 'number' }), null)
109
+ })
110
+
111
+ test('sorbTailwindV3 emits a requireable preset with nested theme.extend of var() refs', () => {
112
+ const dictionary = {
113
+ allTokens: [
114
+ tok('color.action.primary', 'color'),
115
+ tok('button.primary.bg.default', 'color'),
116
+ tok('button.radius', 'dimension'),
117
+ tok('space.100', 'dimension'),
118
+ ],
119
+ }
120
+ const src = sorbTailwindV3({ dictionary })
121
+ assert.match(src, /^module\.exports = /m)
122
+ // evaluate the generated CommonJS to prove it's a valid, requireable preset
123
+ const mod = { exports: {} }
124
+ new Function('module', 'exports', src)(mod, mod.exports)
125
+ const ext = mod.exports.theme.extend
126
+ assert.equal(ext.colors.action.primary, 'var(--color-action-primary)') // → bg-action-primary
127
+ assert.equal(ext.colors.button.primary.bg.default, 'var(--button-primary-bg-default)')
128
+ assert.equal(ext.borderRadius.button, 'var(--button-radius)') // → rounded-button
129
+ assert.equal(ext.spacing['100'], 'var(--space-100)') // → p-100
130
+ // every leaf is a var() ref (the live-preview invariant), never a literal
131
+ const leaves = (o) => Object.values(o).flatMap((v) => (typeof v === 'string' ? [v] : leaves(v)))
132
+ for (const v of leaves(ext)) assert.match(v, /^var\(--[a-z0-9-]+\)$/)
133
+ })
package/src/index.js CHANGED
@@ -5,3 +5,30 @@
5
5
  // so the old `resolveBindableTokens` export is gone. What remains useful as a
6
6
  // library is the token-annotation layer used by `capture`.
7
7
  export { buildTokenIndex, annotateTree, matchColor, matchDimension } from './annotateTokens.js'
8
+
9
+ // Sorb's custom Style Dictionary outputs (component-compat-roadmap P0, part
10
+ // 2) — promoted from sorb-demo's copy-local `sd/sorb-format.js` so target
11
+ // adapters import these formats instead of duplicating them. A consumer's
12
+ // `sd.config.js` registers these with `StyleDictionary.registerFormat`/
13
+ // `registerParser` the same way sorb-demo's does.
14
+ export {
15
+ tierOfFile,
16
+ SORB_RESOLVED,
17
+ SORB_THEME_NESTED,
18
+ SORB_ALIASES,
19
+ SORB_VERSIONS,
20
+ SORB_SET_META,
21
+ SORB_TAILWIND,
22
+ SORB_TAILWIND_V3,
23
+ SORB_TOKENSET,
24
+ sorbSetMeta,
25
+ sorbVersions,
26
+ sorbTokenSet,
27
+ sorbResolved,
28
+ sorbAliases,
29
+ sorbThemeNested,
30
+ tailwindThemeEntry,
31
+ sorbTailwind,
32
+ tailwindV3Slot,
33
+ sorbTailwindV3,
34
+ } from './emit/sorbFormat.js'
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // `sorb-seed render-worker` — E3 hosted-capture on-demand render worker entry.
3
+ // hosted-bridge-modes-exploration-plan.md §3 E3.
4
+ //
5
+ // This is the thin callable seam a cloud off-box runner (queue dispatcher)
6
+ // invokes to run render jobs — mirrors sorb-cloud's `runnerEntry.mjs`
7
+ // NDJSON-over-stdout child-runner contract so the pattern is consistent
8
+ // across the two repos:
9
+ //
10
+ // Input: NDJSON on stdin — one `RenderJobInput` JSON object per line
11
+ // (see worker.js JSDoc). For a one-shot call from a shell, pass
12
+ // `--job='<json>'` or `--job-file=<path>` instead.
13
+ // Output: NDJSON on stdout — one line per job:
14
+ // {"t":"result","data":<RenderJobResult>}
15
+ // {"t":"error","message":"...","job":<the input job>}
16
+ // Diagnostics/logs go to STDERR only — stdout is reserved for the
17
+ // protocol so a queue can pipe it straight into JSON.parse per line.
18
+ //
19
+ // The Playwright page pool persists across jobs read from stdin, so a queue
20
+ // that batches multiple jobs for the SAME url into one process invocation
21
+ // gets pagePool.js's navigation-skip reuse. It is closed once stdin ends (or
22
+ // after the single --job/--job-file run), so the process exits cleanly.
23
+
24
+ import { readFileSync } from 'node:fs'
25
+ import { renderJob } from './worker.js'
26
+ import { createPagePool } from './pagePool.js'
27
+
28
+ const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
29
+
30
+ async function runOneJob(job, pool) {
31
+ try {
32
+ const data = await renderJob(job, { pagePool: async () => pool })
33
+ emit({ t: 'result', data })
34
+ } catch (e) {
35
+ emit({ t: 'error', message: e && e.message ? e.message : String(e), job })
36
+ }
37
+ }
38
+
39
+ async function readStdinJobs() {
40
+ let buf = ''
41
+ const jobs = []
42
+ process.stdin.setEncoding('utf8')
43
+ for await (const chunk of process.stdin) {
44
+ buf += chunk
45
+ let nl
46
+ while ((nl = buf.indexOf('\n')) >= 0) {
47
+ const line = buf.slice(0, nl)
48
+ buf = buf.slice(nl + 1)
49
+ if (line.trim()) jobs.push(JSON.parse(line))
50
+ }
51
+ }
52
+ if (buf.trim()) jobs.push(JSON.parse(buf))
53
+ return jobs
54
+ }
55
+
56
+ export async function main(argv = process.argv) {
57
+ const pool = createPagePool()
58
+ try {
59
+ const jobFileArg = argv.find((a) => a.startsWith('--job-file='))
60
+ const jobArg = argv.find((a) => a.startsWith('--job='))
61
+
62
+ if (jobFileArg || jobArg) {
63
+ const raw = jobFileArg
64
+ ? readFileSync(jobFileArg.slice('--job-file='.length), 'utf-8')
65
+ : jobArg.slice('--job='.length)
66
+ await runOneJob(JSON.parse(raw), pool)
67
+ return
68
+ }
69
+
70
+ const jobs = await readStdinJobs()
71
+ for (const job of jobs) await runOneJob(job, pool)
72
+ } finally {
73
+ await pool.closeAll()
74
+ }
75
+ }
76
+
77
+ if (import.meta.url === `file://${process.argv[1]}`) {
78
+ main().catch((e) => {
79
+ process.stderr.write((e && e.stack ? e.stack : String(e)) + '\n')
80
+ process.exit(1)
81
+ })
82
+ }
@@ -0,0 +1,47 @@
1
+ // cli.js wiring test. Deliberately does NOT exercise a real render (that would
2
+ // launch a real browser) — it drives the `--job=` path with an invalid job
3
+ // (missing `url`), which renderJob rejects BEFORE ever touching the page pool
4
+ // (see worker.js: the url check runs first). That proves the CLI's NDJSON
5
+ // error-emission + pool-lifecycle wiring without needing Playwright/a browser.
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { main } from './cli.js'
9
+
10
+ function captureStdout(fn) {
11
+ const lines = []
12
+ const original = process.stdout.write.bind(process.stdout)
13
+ process.stdout.write = (chunk) => {
14
+ lines.push(String(chunk))
15
+ return true
16
+ }
17
+ return fn().finally(() => {
18
+ process.stdout.write = original
19
+ }).then(() => lines.join(''))
20
+ }
21
+
22
+ test('cli main(): --job= with a missing url emits a {t:"error"} NDJSON line, not a throw', async () => {
23
+ const output = await captureStdout(() => main(['node', 'cli.js', `--job=${JSON.stringify({})}`]))
24
+ const lines = output.trim().split('\n').filter(Boolean)
25
+ assert.equal(lines.length, 1)
26
+ const msg = JSON.parse(lines[0])
27
+ assert.equal(msg.t, 'error')
28
+ assert.match(msg.message, /url is required/)
29
+ assert.deepEqual(msg.job, {})
30
+ })
31
+
32
+ test('cli main(): --job-file= reads the job from disk', async () => {
33
+ const { writeFile, rm } = await import('node:fs/promises')
34
+ const { join } = await import('node:path')
35
+ const { tmpdir } = await import('node:os')
36
+ const { randomUUID } = await import('node:crypto')
37
+ const path = join(tmpdir(), `sorb-cli-test-${randomUUID()}.json`)
38
+ await writeFile(path, JSON.stringify({}))
39
+ try {
40
+ const output = await captureStdout(() => main(['node', 'cli.js', `--job-file=${path}`]))
41
+ const msg = JSON.parse(output.trim())
42
+ assert.equal(msg.t, 'error')
43
+ assert.match(msg.message, /url is required/)
44
+ } finally {
45
+ await rm(path, { force: true })
46
+ }
47
+ })
@@ -0,0 +1,152 @@
1
+ // E3 (hosted-capture render worker) — diff-only cache spike.
2
+ //
3
+ // Two independent wins, both real (not stubbed), scoped honestly:
4
+ // 1. EXACT-MATCH short-circuit: if a (url, tokenMap) pair was rendered before
5
+ // and the token map is byte-identical, skip the render entirely and return
6
+ // the cached result. This is the biggest, cheapest win during the credit
7
+ // window (repeat previews of an unchanged proposal).
8
+ // 2. CHANGED-SUBTREE REPORT: when the token map differs from the last render
9
+ // of the SAME url, we still do a full page recapture (Playwright reads the
10
+ // whole rendered DOM in one pass — there is no cheap partial DOM read), but
11
+ // we diff the new capture's per-node hashes against the previous one and
12
+ // report which subtrees actually changed. That's real, useful signal for a
13
+ // caller (cloud telemetry / a future incremental-repaint UI) even though the
14
+ // RENDER itself is not selectively re-executed.
15
+ //
16
+ // TRUE selective re-render (only re-rendering the changed subtrees inside the
17
+ // browser, skipping layout for the rest) is NOT implemented — it would require
18
+ // either a persistent, patchable page (partial `page.evaluate` reflow, which
19
+ // Chromium doesn't expose cleanly) or a virtual-DOM diffing shim injected into
20
+ // the target app (out of our control since Mode B targets arbitrary apps). This
21
+ // is the honest boundary called out in hosted-bridge-modes-exploration-plan.md
22
+ // §3 E3 ("keep it behind a flag/option; correctness first, optimization second
23
+ // — leave a documented stub"). What IS implemented (page reuse — skip
24
+ // navigation on a cache-miss-but-same-url render) lives in `pagePool.js`.
25
+
26
+ import { createHash } from 'node:crypto'
27
+
28
+ /** Stable JSON stringify (sorted keys) so token-map key order never changes the hash. */
29
+ export const stableStringify = (value) => {
30
+ const sortKeys = (v) => {
31
+ if (Array.isArray(v)) return v.map(sortKeys)
32
+ if (v && typeof v === 'object') {
33
+ return Object.keys(v)
34
+ .sort()
35
+ .reduce((acc, k) => {
36
+ acc[k] = sortKeys(v[k])
37
+ return acc
38
+ }, {})
39
+ }
40
+ return v
41
+ }
42
+ return JSON.stringify(sortKeys(value))
43
+ }
44
+
45
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex')
46
+
47
+ /** Hash a token map (cssVar -> value), order-independent. */
48
+ export const hashTokenMap = (tokenMap) => 'sha256:' + sha256(stableStringify(tokenMap || {}))
49
+
50
+ /** The cache key for a render: (url, tokenMap-hash). */
51
+ export const makeCacheKey = (url, tokenMap) => `${url}::${hashTokenMap(tokenMap)}`
52
+
53
+ /**
54
+ * Per-node hash map keyed by a stable structural path ("0.2.1" = root's 3rd
55
+ * child's 2nd child), so two trees of the same shape can be compared node-by-
56
+ * node even though DOM nodes have no stable id. Hash excludes nothing — the
57
+ * whole node's own fields (not descendants) plus a summary of children count,
58
+ * so a change anywhere down a branch bubbles up as a changed hash at every
59
+ * ancestor on that branch (cheap "which top-level regions changed" signal).
60
+ * @param {object} tree LayerNode-shaped tree (root, with .children[])
61
+ * @returns {Map<string,string>} path -> sha256 hash
62
+ */
63
+ export const hashNodePaths = (tree) => {
64
+ const out = new Map()
65
+ const walk = (node, path) => {
66
+ if (!node) return
67
+ const { children, ...ownFields } = node
68
+ const own = sha256(stableStringify(ownFields))
69
+ const childHashes = (children || []).map((c, i) => walk(c, path ? `${path}.${i}` : String(i)))
70
+ const combined = sha256(own + '|' + childHashes.join(','))
71
+ out.set(path || '0', combined)
72
+ return combined
73
+ }
74
+ walk(tree, '0')
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * Diff two node-path hash maps.
80
+ * @returns {{changed:string[], added:string[], removed:string[]}} paths present
81
+ * in both but with a different hash / only in `next` / only in `prev`.
82
+ */
83
+ export const diffNodeHashes = (prevHashes, nextHashes) => {
84
+ const changed = []
85
+ const added = []
86
+ const removed = []
87
+ for (const [path, hash] of nextHashes) {
88
+ if (!prevHashes.has(path)) added.push(path)
89
+ else if (prevHashes.get(path) !== hash) changed.push(path)
90
+ }
91
+ for (const path of prevHashes.keys()) {
92
+ if (!nextHashes.has(path)) removed.push(path)
93
+ }
94
+ return { changed, added, removed }
95
+ }
96
+
97
+ /**
98
+ * In-memory diff-cache. One process/worker instance's lifetime — not persisted.
99
+ * `capacity` bounds memory (LRU-by-insertion via Map iteration order); default
100
+ * is generous since a render result's dom tree is the only heavy field kept.
101
+ */
102
+ export class DiffCache {
103
+ constructor({ capacity = 200 } = {}) {
104
+ this.capacity = capacity
105
+ /** exact (url,tokenMap-hash) -> full render result */
106
+ this.exact = new Map()
107
+ /** url -> { tokenMap, hashes: Map<path,hash> } — last render, ANY token map */
108
+ this.byUrl = new Map()
109
+ }
110
+
111
+ /** Exact-match lookup: same url + byte-identical token map. */
112
+ getExact(url, tokenMap) {
113
+ return this.exact.get(makeCacheKey(url, tokenMap))
114
+ }
115
+
116
+ /** Record an exact-match entry (called after every successful render). */
117
+ putExact(url, tokenMap, result) {
118
+ const key = makeCacheKey(url, tokenMap)
119
+ this.exact.set(key, result)
120
+ this._evictIfNeeded(this.exact)
121
+ }
122
+
123
+ /**
124
+ * Diff a freshly-captured tree against the last capture for this URL
125
+ * (regardless of token map), returning which node paths changed. Also
126
+ * records this capture as the new "last" for the url. Returns `null` when
127
+ * there is no prior capture to diff against (first render for this url).
128
+ */
129
+ diffAgainstLastForUrl(url, tokenMap, tree) {
130
+ const prev = this.byUrl.get(url)
131
+ const nextHashes = hashNodePaths(tree)
132
+ let diff = null
133
+ if (prev) {
134
+ diff = diffNodeHashes(prev.hashes, nextHashes)
135
+ }
136
+ this.byUrl.set(url, { tokenMap, hashes: nextHashes })
137
+ this._evictIfNeeded(this.byUrl)
138
+ return diff
139
+ }
140
+
141
+ _evictIfNeeded(map) {
142
+ while (map.size > this.capacity) {
143
+ const oldestKey = map.keys().next().value
144
+ map.delete(oldestKey)
145
+ }
146
+ }
147
+
148
+ clear() {
149
+ this.exact.clear()
150
+ this.byUrl.clear()
151
+ }
152
+ }
@@ -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
+ })