@sorb/seed 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +15 -5
- package/package.json +6 -3
- package/src/annotateTokens.js +161 -19
- package/src/annotateTokens.test.js +208 -0
- package/src/capture.js +100 -0
- package/src/capture.test.js +142 -0
- package/src/captureCli.js +29 -2
- package/src/cli.js +185 -3
- package/src/cli.test.js +51 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, existsSync } from 'fs'
|
|
3
|
-
import { resolve } from 'path'
|
|
3
|
+
import { resolve, dirname } from 'path'
|
|
4
4
|
import { execSync } from 'child_process'
|
|
5
5
|
|
|
6
6
|
// sorb-seed — Storybook → Figma capture tooling.
|
|
@@ -25,7 +25,60 @@ const loadConfig = () => {
|
|
|
25
25
|
|
|
26
26
|
const cmd = process.argv[2] || 'resolve'
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const pkgVersion = () => {
|
|
29
|
+
try {
|
|
30
|
+
const pkgPath = resolve(dirname(new URL(import.meta.url).pathname), '..', 'package.json')
|
|
31
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return '0.0.0'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HELP = `sorb-seed — Storybook → Figma token capture for Sorb.
|
|
38
|
+
|
|
39
|
+
Usage: sorb-seed <command> [options]
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
resolve Build .sorb/resolved.json from your DTCG token sets via
|
|
43
|
+
Style Dictionary (the default when no command is given).
|
|
44
|
+
capture Visit each Storybook story with Playwright, capture the
|
|
45
|
+
rendered tree, and annotate it against the resolved token
|
|
46
|
+
map → per-component *.sorb.json + .sorb/index.json.
|
|
47
|
+
render-worker Hosted-capture on-demand render worker (Mode B / E3):
|
|
48
|
+
reads RenderJobInput job(s) as NDJSON on stdin (or
|
|
49
|
+
--job=/--job-file=), renders each against a URL + token
|
|
50
|
+
map, emits RenderJobResult NDJSON on stdout. See
|
|
51
|
+
src/render/worker.js.
|
|
52
|
+
variant <add|deprecate>
|
|
53
|
+
Add or deprecate a component variant in the DTCG source,
|
|
54
|
+
bump $version, and rebuild. See \`variant\` usage below.
|
|
55
|
+
|
|
56
|
+
variant options:
|
|
57
|
+
variant add <id> --from <base> [--tokens-dir <dir>] [--sd-dir <dir>]
|
|
58
|
+
variant deprecate <id> --replaced-by <replacement> [--tokens-dir <dir>] [--sd-dir <dir>]
|
|
59
|
+
|
|
60
|
+
capture options:
|
|
61
|
+
--changed Only re-capture stories whose rendered hash changed.
|
|
62
|
+
--only=<pattern> Capture only stories matching the glob/regex (matched
|
|
63
|
+
against importPath, title, and id).
|
|
64
|
+
--storybook-url=<url>
|
|
65
|
+
Storybook base URL (default: sorb.config.json seed.storybookUrl
|
|
66
|
+
or http://localhost:6006).
|
|
67
|
+
|
|
68
|
+
Global:
|
|
69
|
+
-h, --help Show this help and exit.
|
|
70
|
+
-v, --version Print the sorb-seed version and exit.
|
|
71
|
+
|
|
72
|
+
capture needs Playwright + its Chromium browser:
|
|
73
|
+
npm install playwright && npx playwright install chromium`
|
|
74
|
+
|
|
75
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
76
|
+
console.log(HELP)
|
|
77
|
+
process.exit(0)
|
|
78
|
+
} else if (cmd === '--version' || cmd === '-v') {
|
|
79
|
+
console.log(pkgVersion())
|
|
80
|
+
process.exit(0)
|
|
81
|
+
} else if (cmd === 'resolve') {
|
|
29
82
|
const config = loadConfig()
|
|
30
83
|
const sdConfig = config.styleDictionaryConfig || 'sd.config.js'
|
|
31
84
|
const abs = resolve(cwd, sdConfig)
|
|
@@ -53,7 +106,136 @@ if (cmd === 'resolve') {
|
|
|
53
106
|
else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
|
|
54
107
|
}
|
|
55
108
|
await runCapture(opts)
|
|
109
|
+
} else if (cmd === 'render-worker') {
|
|
110
|
+
const { main: renderWorkerMain } = await import('./render/cli.js')
|
|
111
|
+
await renderWorkerMain(process.argv)
|
|
112
|
+
} else if (cmd === 'variant') {
|
|
113
|
+
const sub = process.argv[3]
|
|
114
|
+
|
|
115
|
+
// ── shared arg parser ──────────────────────────────────────────────────────
|
|
116
|
+
const args = process.argv.slice(4)
|
|
117
|
+
const flag = (name) => {
|
|
118
|
+
const prefix = `--${name}=`
|
|
119
|
+
const eq = args.find((a) => a.startsWith(prefix))
|
|
120
|
+
if (eq) return eq.slice(prefix.length)
|
|
121
|
+
const idx = args.indexOf(`--${name}`)
|
|
122
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1]
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── auto-detect sorb-demo paths ────────────────────────────────────────────
|
|
127
|
+
// Walks up from cwd looking for sorb-demo/tokens/component.json and
|
|
128
|
+
// sorb-demo/sd.config.js. Falls back to cwd-relative guesses.
|
|
129
|
+
const autoDetect = () => {
|
|
130
|
+
// Try workspace-sibling layout: cwd may be sorb-seed or any sibling
|
|
131
|
+
const candidates = [
|
|
132
|
+
resolve(cwd, '../sorb-demo'),
|
|
133
|
+
resolve(cwd, 'sorb-demo'),
|
|
134
|
+
cwd,
|
|
135
|
+
]
|
|
136
|
+
for (const base of candidates) {
|
|
137
|
+
const tokens = resolve(base, 'tokens/component.json')
|
|
138
|
+
const sd = resolve(base, 'sd.config.js')
|
|
139
|
+
if (existsSync(tokens) && existsSync(sd)) return { tokensDir: resolve(base, 'tokens'), sdDir: base }
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (sub === 'add') {
|
|
145
|
+
const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
|
|
146
|
+
const fromRaw = flag('from')
|
|
147
|
+
const tokensDirFlag = flag('tokens-dir')
|
|
148
|
+
const sdDirFlag = flag('sd-dir')
|
|
149
|
+
|
|
150
|
+
if (!variantId) {
|
|
151
|
+
console.error('✗ Usage: sorb-seed variant add <variantId> --from <fromVariant> [--tokens-dir <dir>] [--sd-dir <dir>]')
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
if (!fromRaw) {
|
|
155
|
+
console.error('✗ --from is required (e.g. --from primary or --from button.primary)')
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Normalise --from: if it has no dot, prepend the component from variantId
|
|
160
|
+
const componentId = variantId.split('.')[0]
|
|
161
|
+
const fromVariant = fromRaw.includes('.') ? fromRaw : `${componentId}.${fromRaw}`
|
|
162
|
+
|
|
163
|
+
// Resolve paths
|
|
164
|
+
let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
|
|
165
|
+
let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
|
|
166
|
+
if (!tokensDir || !sdDir) {
|
|
167
|
+
const detected = autoDetect()
|
|
168
|
+
if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
|
|
169
|
+
if (!sdDir) sdDir = detected ? detected.sdDir : cwd
|
|
170
|
+
}
|
|
171
|
+
const componentJsonPath = resolve(tokensDir, 'component.json')
|
|
172
|
+
|
|
173
|
+
if (!existsSync(componentJsonPath)) {
|
|
174
|
+
console.error(`✗ component.json not found at ${componentJsonPath}`)
|
|
175
|
+
console.error(' Use --tokens-dir to point at your tokens directory.')
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { addVariant } = await import('./variants.js')
|
|
180
|
+
try {
|
|
181
|
+
const changeset = await addVariant({ componentJsonPath, variantId, fromVariant, sdConfigDir: sdDir })
|
|
182
|
+
console.log(`✓ Added variant ${changeset.variantId} (${changeset.tokenIds.length} tokens) — $version bumped to ${changeset.newVersion}`)
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.error('✗', e.message)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
} else if (sub === 'deprecate') {
|
|
189
|
+
const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
|
|
190
|
+
const replacedBy = flag('replaced-by')
|
|
191
|
+
const tokensDirFlag = flag('tokens-dir')
|
|
192
|
+
const sdDirFlag = flag('sd-dir')
|
|
193
|
+
|
|
194
|
+
if (!variantId) {
|
|
195
|
+
console.error('✗ Usage: sorb-seed variant deprecate <variantId> --replaced-by <replacedBy> [--tokens-dir <dir>] [--sd-dir <dir>]')
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
if (!replacedBy) {
|
|
199
|
+
console.error('✗ --replaced-by is required (e.g. --replaced-by button.error)')
|
|
200
|
+
process.exit(1)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolve paths
|
|
204
|
+
let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
|
|
205
|
+
let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
|
|
206
|
+
if (!tokensDir || !sdDir) {
|
|
207
|
+
const detected = autoDetect()
|
|
208
|
+
if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
|
|
209
|
+
if (!sdDir) sdDir = detected ? detected.sdDir : cwd
|
|
210
|
+
}
|
|
211
|
+
const componentJsonPath = resolve(tokensDir, 'component.json')
|
|
212
|
+
|
|
213
|
+
if (!existsSync(componentJsonPath)) {
|
|
214
|
+
console.error(`✗ component.json not found at ${componentJsonPath}`)
|
|
215
|
+
console.error(' Use --tokens-dir to point at your tokens directory.')
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Normalise replacedBy: if no dot, prepend the component from variantId
|
|
220
|
+
const componentId = variantId.split('.')[0]
|
|
221
|
+
const replacedByFull = replacedBy.includes('.') ? replacedBy : `${componentId}.${replacedBy}`
|
|
222
|
+
|
|
223
|
+
const { deprecateVariant } = await import('./variants.js')
|
|
224
|
+
try {
|
|
225
|
+
const changeset = await deprecateVariant({ componentJsonPath, variantId, replacedBy: replacedByFull, sdConfigDir: sdDir })
|
|
226
|
+
console.log(`✓ Deprecated variant ${changeset.variantId} → ${replacedByFull} (${changeset.tokenIds.length} tokens marked)`)
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error('✗', e.message)
|
|
229
|
+
process.exit(1)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
} else {
|
|
233
|
+
console.error(`✗ Unknown variant subcommand: ${sub || '(none)'}`)
|
|
234
|
+
console.error(' Usage: sorb-seed variant <add|deprecate> ...')
|
|
235
|
+
process.exit(1)
|
|
236
|
+
}
|
|
237
|
+
|
|
56
238
|
} else {
|
|
57
|
-
console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture> [options]
|
|
239
|
+
console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture|render-worker|variant> [options]\nRun \`sorb-seed --help\` for details.`)
|
|
58
240
|
process.exit(1)
|
|
59
241
|
}
|
package/src/cli.test.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Smoke tests for the `sorb-seed` CLI entrypoint — --help / --version / unknown
|
|
2
|
+
// command dispatch (GFP RC1 Part 2 · D4). Spawns the real bin so exit codes and
|
|
3
|
+
// stdout/stderr are exercised end-to-end. Run: `node --test src/cli.test.js`.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { dirname, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const CLI = resolve(here, 'cli.js')
|
|
14
|
+
const pkgVersion = JSON.parse(
|
|
15
|
+
readFileSync(resolve(here, '..', 'package.json'), 'utf-8'),
|
|
16
|
+
).version
|
|
17
|
+
|
|
18
|
+
const run = (args) => spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf-8' })
|
|
19
|
+
|
|
20
|
+
test('--help prints usage, lists resolve+capture, exits 0', () => {
|
|
21
|
+
const r = run(['--help'])
|
|
22
|
+
assert.equal(r.status, 0)
|
|
23
|
+
assert.match(r.stdout, /Usage: sorb-seed/)
|
|
24
|
+
assert.match(r.stdout, /\bresolve\b/)
|
|
25
|
+
assert.match(r.stdout, /\bcapture\b/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('-h is an alias for --help', () => {
|
|
29
|
+
const r = run(['-h'])
|
|
30
|
+
assert.equal(r.status, 0)
|
|
31
|
+
assert.match(r.stdout, /Usage: sorb-seed/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('--version prints the package.json version, exits 0', () => {
|
|
35
|
+
const r = run(['--version'])
|
|
36
|
+
assert.equal(r.status, 0)
|
|
37
|
+
assert.equal(r.stdout.trim(), pkgVersion)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('-v is an alias for --version', () => {
|
|
41
|
+
const r = run(['-v'])
|
|
42
|
+
assert.equal(r.status, 0)
|
|
43
|
+
assert.equal(r.stdout.trim(), pkgVersion)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('unknown command exits 1 and points at --help', () => {
|
|
47
|
+
const r = run(['bogus'])
|
|
48
|
+
assert.equal(r.status, 1)
|
|
49
|
+
assert.match(r.stderr, /Unknown command: bogus/)
|
|
50
|
+
assert.match(r.stderr, /--help/)
|
|
51
|
+
})
|
|
@@ -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
|
+
})
|