@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.
- package/README.md +15 -5
- package/package.json +10 -3
- package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
- package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
- package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
- package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
- package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
- package/src/adapt/adaptCli.js +110 -0
- package/src/adapt/adaptCli.test.js +109 -0
- package/src/adapt/benchmark.js +113 -0
- package/src/adapt/benchmark.test.js +79 -0
- package/src/adapt/codemod.js +160 -0
- package/src/adapt/codemod.test.js +120 -0
- package/src/adapt/detectHardcoded.js +165 -0
- package/src/adapt/detectHardcoded.test.js +62 -0
- package/src/adapt/glob.js +87 -0
- package/src/adapt/mapToToken.js +94 -0
- package/src/adapt/mapToToken.test.js +103 -0
- package/src/adapt/report.js +53 -0
- package/src/adapt/report.test.js +66 -0
- package/src/adapt/runBenchmark.js +16 -0
- package/src/adapt/types.js +48 -0
- package/src/captureCli.js +38 -118
- package/src/cli.js +200 -3
- package/src/cli.test.js +51 -0
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/index.js +27 -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/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- package/src/sources/storybookDom.js +188 -0
- package/src/variants.js +279 -0
- 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
|
+
})
|