@sorb/seed 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ // Unit tests for the E3 render-worker orchestration. NO real Playwright/browser
2
+ // is used — `page`/`pool`/`cache` are all fakes injected via `deps`, so these
3
+ // tests exercise job orchestration, token-injection wiring, the diff-cache, and
4
+ // the result shape without a live browser (per the task's hard rule).
5
+
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFile, rm } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { randomUUID } from 'node:crypto'
12
+
13
+ import { renderJob } from './worker.js'
14
+ import { DiffCache } from './diffCache.js'
15
+
16
+ // ─── fakes ────────────────────────────────────────────────────────────────────
17
+ function makeFakePage({ resolvedVars = {} } = {}) {
18
+ const calls = { evaluate: 0, screenshot: 0 }
19
+ return {
20
+ calls,
21
+ async evaluate() {
22
+ calls.evaluate++
23
+ // 1st call in renderJob is injectTokenMap (no meaningful return needed);
24
+ // 2nd is readResolvedVars, which needs the resolved-values shape back.
25
+ if (calls.evaluate === 1) return undefined
26
+ return resolvedVars
27
+ },
28
+ async screenshot() {
29
+ calls.screenshot++
30
+ return Buffer.from('fake-png-bytes')
31
+ },
32
+ async addInitScript() {},
33
+ async setViewportSize() {},
34
+ }
35
+ }
36
+
37
+ function makeFakePool(pagesByUrl = {}) {
38
+ const acquireCalls = []
39
+ const seen = new Set()
40
+ return {
41
+ acquireCalls,
42
+ async acquire(url, viewport) {
43
+ acquireCalls.push({ url, viewport })
44
+ const reused = seen.has(url)
45
+ seen.add(url)
46
+ const page = pagesByUrl[url] || (pagesByUrl[url] = makeFakePage())
47
+ return { page, reused }
48
+ },
49
+ async evict() {},
50
+ async closeAll() {},
51
+ }
52
+ }
53
+
54
+ const scratchDir = tmpdir()
55
+ const scratchPath = () => join(scratchDir, `sorb-worker-test-${randomUUID()}.png`)
56
+
57
+ // ─── basic shape + reuse ──────────────────────────────────────────────────────
58
+ test('renderJob: requires input.url', async () => {
59
+ await assert.rejects(() => renderJob({}), /url is required/)
60
+ })
61
+
62
+ test('renderJob: returns the documented result shape and writes the screenshot file', async () => {
63
+ const pool = makeFakePool()
64
+ const cache = new DiffCache()
65
+ const path = scratchPath()
66
+ let captureCalls = 0
67
+ const captureFn = async () => {
68
+ captureCalls++
69
+ return { type: 'FRAME', name: 'body', fills: [], children: [] }
70
+ }
71
+
72
+ const result = await renderJob(
73
+ {
74
+ url: 'https://demo.sorbcloud.com',
75
+ tokenMap: { '--sorb-color-bg': '#fff' },
76
+ screenshotPath: path,
77
+ },
78
+ { pagePool: async () => pool, cache, captureFn },
79
+ )
80
+
81
+ try {
82
+ assert.equal(result.cacheHit, false)
83
+ assert.equal(result.pageReused, false)
84
+ assert.equal(captureCalls, 1)
85
+ assert.equal(result.screenshot.path, path)
86
+ assert.equal(result.screenshot.width, 1280)
87
+ assert.equal(result.screenshot.height, 800)
88
+ assert.ok(result.dom)
89
+ assert.ok(result.conformance)
90
+ assert.equal(typeof result.conformance.conformant, 'boolean')
91
+ assert.ok(result.timings)
92
+ for (const k of ['navigateMs', 'injectMs', 'captureMs', 'screenshotMs', 'totalMs']) {
93
+ assert.equal(typeof result.timings[k], 'number')
94
+ }
95
+ assert.equal(result.diff, null) // first render for this url → no prior to diff against
96
+ assert.ok(result.cacheKey.includes('https://demo.sorbcloud.com'))
97
+
98
+ const written = await readFile(path)
99
+ assert.equal(written.toString(), 'fake-png-bytes')
100
+ } finally {
101
+ await rm(path, { force: true })
102
+ }
103
+ })
104
+
105
+ // ─── exact-match cache short-circuit ─────────────────────────────────────────
106
+ test('renderJob: identical (url, tokenMap) on a warm cache short-circuits the render', async () => {
107
+ const pool = makeFakePool()
108
+ const cache = new DiffCache()
109
+ let captureCalls = 0
110
+ const captureFn = async () => {
111
+ captureCalls++
112
+ return { type: 'FRAME', children: [] }
113
+ }
114
+ const input = {
115
+ url: 'https://demo.sorbcloud.com',
116
+ tokenMap: { '--x': '1px' },
117
+ screenshotPath: scratchPath(),
118
+ }
119
+ const deps = { pagePool: async () => pool, cache, captureFn }
120
+
121
+ const r1 = await renderJob(input, deps)
122
+ const r2 = await renderJob({ ...input, screenshotPath: scratchPath() }, deps)
123
+
124
+ assert.equal(r1.cacheHit, false)
125
+ assert.equal(r2.cacheHit, true)
126
+ assert.equal(captureCalls, 1) // second call never re-rendered
127
+ assert.equal(r2.screenshot.path, r1.screenshot.path) // cached result's own screenshot ref
128
+ await rm(r1.screenshot.path, { force: true })
129
+ })
130
+
131
+ test('renderJob: different tokenMap for the same url is NOT a cache hit', async () => {
132
+ const pool = makeFakePool()
133
+ const cache = new DiffCache()
134
+ let captureCalls = 0
135
+ const captureFn = async () => {
136
+ captureCalls++
137
+ return { type: 'FRAME', children: [] }
138
+ }
139
+ const deps = { pagePool: async () => pool, cache, captureFn }
140
+
141
+ const p1 = scratchPath()
142
+ const p2 = scratchPath()
143
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1px' }, screenshotPath: p1 }, deps)
144
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2px' }, screenshotPath: p2 }, deps)
145
+
146
+ assert.equal(r1.cacheHit, false)
147
+ assert.equal(r2.cacheHit, false)
148
+ assert.equal(captureCalls, 2)
149
+ await rm(p1, { force: true })
150
+ await rm(p2, { force: true })
151
+ })
152
+
153
+ // ─── page reuse (navigation-skip) ────────────────────────────────────────────
154
+ test('renderJob: a second render of the same url reuses the page (pageReused=true)', async () => {
155
+ const pool = makeFakePool()
156
+ const cache = new DiffCache()
157
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
158
+ const deps = { pagePool: async () => pool, cache, captureFn }
159
+
160
+ const p1 = scratchPath()
161
+ const p2 = scratchPath()
162
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1px' }, screenshotPath: p1 }, deps)
163
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2px' }, screenshotPath: p2 }, deps)
164
+
165
+ assert.equal(r1.pageReused, false)
166
+ assert.equal(r2.pageReused, true)
167
+ assert.equal(pool.acquireCalls.length, 2)
168
+ await rm(p1, { force: true })
169
+ await rm(p2, { force: true })
170
+ })
171
+
172
+ // ─── diff-cache reporting ─────────────────────────────────────────────────────
173
+ test('renderJob: diffOnly=true reports which dom subtree changed on a re-render', async () => {
174
+ const pool = makeFakePool()
175
+ const cache = new DiffCache()
176
+ let call = 0
177
+ const captureFn = async () => {
178
+ call++
179
+ // second capture differs from the first (a changed fill on the same shape)
180
+ return {
181
+ type: 'FRAME',
182
+ children: [{ type: 'FRAME', fills: call === 1 ? [] : [{ raw: '#fff' }], children: [] }],
183
+ }
184
+ }
185
+ const deps = { pagePool: async () => pool, cache, captureFn }
186
+
187
+ const p1 = scratchPath()
188
+ const p2 = scratchPath()
189
+ const r1 = await renderJob({ url: 'https://a', tokenMap: { '--x': '1' }, screenshotPath: p1, diffOnly: true }, deps)
190
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2' }, screenshotPath: p2, diffOnly: true }, deps)
191
+
192
+ assert.equal(r1.diff, null)
193
+ assert.ok(r2.diff)
194
+ assert.ok(r2.diff.changed.length > 0)
195
+ await rm(p1, { force: true })
196
+ await rm(p2, { force: true })
197
+ })
198
+
199
+ test('renderJob: diffOnly=false skips the diff computation entirely', async () => {
200
+ const pool = makeFakePool()
201
+ const cache = new DiffCache()
202
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
203
+ const deps = { pagePool: async () => pool, cache, captureFn }
204
+
205
+ const p1 = scratchPath()
206
+ const p2 = scratchPath()
207
+ await renderJob({ url: 'https://a', tokenMap: { '--x': '1' }, screenshotPath: p1, diffOnly: false }, deps)
208
+ const r2 = await renderJob({ url: 'https://a', tokenMap: { '--x': '2' }, screenshotPath: p2, diffOnly: false }, deps)
209
+
210
+ assert.equal(r2.diff, null)
211
+ await rm(p1, { force: true })
212
+ await rm(p2, { force: true })
213
+ })
214
+
215
+ // ─── conformance wiring end-to-end (with the captureFn/page fakes) ──────────
216
+ test('renderJob: conformance reflects the resolved vs requested token values', async () => {
217
+ const page = makeFakePage({ resolvedVars: { '--sorb-color-bg': '#000' } }) // mismatch on purpose
218
+ const pool = { async acquire() { return { page, reused: false } }, async evict() {}, async closeAll() {} }
219
+ const cache = new DiffCache()
220
+ const captureFn = async () => ({ type: 'FRAME', children: [] })
221
+ const path = scratchPath()
222
+
223
+ const result = await renderJob(
224
+ { url: 'https://a', tokenMap: { '--sorb-color-bg': '#fff' }, screenshotPath: path },
225
+ { pagePool: async () => pool, cache, captureFn },
226
+ )
227
+
228
+ assert.equal(result.conformance.conformant, false)
229
+ assert.equal(result.conformance.mismatchCount, 1)
230
+ assert.equal(result.conformance.rows[0].expected, '#fff')
231
+ assert.equal(result.conformance.rows[0].actual, '#000')
232
+ await rm(path, { force: true })
233
+ })
@@ -0,0 +1,279 @@
1
+ // sorb-seed — variant lifecycle engine.
2
+ //
3
+ // Provides add and deprecate operations against DTCG component token sources.
4
+ // Each operation mutates the source JSON, bumps the $version, re-runs the SD
5
+ // build, and returns a VariantChangeset describing what changed.
6
+
7
+ import { readFileSync, writeFileSync } from 'fs'
8
+ import { spawn } from 'child_process'
9
+
10
+ // ─── types ───────────────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * @typedef {Object} AddVariantOptions
14
+ * @property {string} componentJsonPath - Absolute path to the component DTCG JSON file.
15
+ * @property {string} variantId - Full dot-path of the new variant, e.g. "button.tertiary".
16
+ * @property {string} fromVariant - Full dot-path of the source variant, e.g. "button.primary".
17
+ * @property {string} sdConfigDir - Directory containing sd.config.js (runs `npm run tokens` here).
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} DeprecateVariantOptions
22
+ * @property {string} componentJsonPath - Absolute path to the component DTCG JSON file.
23
+ * @property {string} variantId - Full dot-path of the variant to deprecate, e.g. "button.danger".
24
+ * @property {string} replacedBy - Replacement variant dot-path, e.g. "button.error".
25
+ * @property {string} sdConfigDir - Directory containing sd.config.js.
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} VariantChangeset
30
+ * @property {'add'|'deprecate'} action - Which operation was performed.
31
+ * @property {string} variantId - The target variant dot-path.
32
+ * @property {string[]} tokenIds - All token ids affected (leaf nodes).
33
+ * @property {string} newVersion - The bumped $version written to the source file.
34
+ */
35
+
36
+ // ─── helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Bump the semver patch segment of a version string.
40
+ * Non-parseable input returns "1.0.1" as a safe fallback.
41
+ * @param {string} version
42
+ * @returns {string}
43
+ */
44
+ export const bumpVersion = (version) => {
45
+ if (typeof version !== 'string') return '1.0.1'
46
+ const parts = version.split('.')
47
+ if (parts.length !== 3) return '1.0.1'
48
+ const [major, minor, patch] = parts.map(Number)
49
+ if (parts.some((p, i) => isNaN([major, minor, patch][i]))) return '1.0.1'
50
+ return `${major}.${minor}.${patch + 1}`
51
+ }
52
+
53
+ /**
54
+ * Walk a variant slice (prop × state tree) and collect fully-qualified token
55
+ * ids for every leaf node (a node that has a `$value` property).
56
+ * @param {string} componentId - e.g. "button"
57
+ * @param {string} variantKey - e.g. "tertiary"
58
+ * @param {Object} sliceObj - The variant's token subtree.
59
+ * @returns {string[]}
60
+ */
61
+ export const flattenVariantIds = (componentId, variantKey, sliceObj) => {
62
+ const ids = []
63
+
64
+ /**
65
+ * @param {Object} node
66
+ * @param {string[]} path - segments accumulated so far (below variantKey)
67
+ */
68
+ const walk = (node, path) => {
69
+ if (node === null || typeof node !== 'object') return
70
+ if ('$value' in node) {
71
+ // Leaf token — build the full dot-path id.
72
+ ids.push([componentId, variantKey, ...path].join('.'))
73
+ return
74
+ }
75
+ for (const key of Object.keys(node)) {
76
+ if (key.startsWith('$')) continue // skip metadata keys ($type, $description, etc.)
77
+ walk(node[key], [...path, key])
78
+ }
79
+ }
80
+
81
+ walk(sliceObj, [])
82
+ return ids
83
+ }
84
+
85
+ /**
86
+ * Deep-clone a variant slice (the value stored under a variant key).
87
+ * Equivalent to JSON round-trip — suitable for pure token objects.
88
+ * @param {Object} fromSlice
89
+ * @returns {Object}
90
+ */
91
+ export const cloneVariantSlice = (fromSlice) => JSON.parse(JSON.stringify(fromSlice))
92
+
93
+ /**
94
+ * Walk a variant slice and mark every leaf node as deprecated, setting
95
+ * `$deprecated = true` and `$extensions.sorb.replacedBy` to `replacedBy`.
96
+ * Returns a **deep clone** with the mutations applied; the original is never
97
+ * touched.
98
+ * @param {Object} sliceObj - The variant's token subtree.
99
+ * @param {string} replacedBy - Replacement variant dot-path.
100
+ * @returns {Object} mutated deep clone
101
+ */
102
+ export const applyDeprecation = (sliceObj, replacedBy) => {
103
+ const clone = cloneVariantSlice(sliceObj)
104
+
105
+ const walk = (node) => {
106
+ if (node === null || typeof node !== 'object') return
107
+ if ('$value' in node) {
108
+ node.$deprecated = true
109
+ node.$extensions = { sorb: { replacedBy } }
110
+ return
111
+ }
112
+ for (const key of Object.keys(node)) {
113
+ if (key.startsWith('$')) continue
114
+ walk(node[key])
115
+ }
116
+ }
117
+
118
+ walk(clone)
119
+ return clone
120
+ }
121
+
122
+ /**
123
+ * Run `npm run tokens` in the given directory and return a promise that
124
+ * resolves on exit 0 or rejects with the collected output on non-zero exit.
125
+ * @param {string} sdConfigDir
126
+ * @returns {Promise<void>}
127
+ */
128
+ const runSdBuild = (sdConfigDir) =>
129
+ new Promise((resolve, reject) => {
130
+ const chunks = []
131
+ const child = spawn('npm', ['run', 'tokens'], {
132
+ cwd: sdConfigDir,
133
+ stdio: 'pipe',
134
+ })
135
+ child.stdout.on('data', (d) => chunks.push(d))
136
+ child.stderr.on('data', (d) => chunks.push(d))
137
+ child.on('close', (code) => {
138
+ if (code === 0) {
139
+ resolve()
140
+ } else {
141
+ const output = Buffer.concat(chunks).toString('utf-8')
142
+ reject(
143
+ new Error(
144
+ `SD build failed (exit ${code}) in ${sdConfigDir}:\n${output}`,
145
+ ),
146
+ )
147
+ }
148
+ })
149
+ child.on('error', (err) => reject(err))
150
+ })
151
+
152
+ // ─── exported API ─────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Add a new component variant by cloning an existing one.
156
+ *
157
+ * Reads `componentJsonPath`, deep-clones the `fromVariant` slice into a new
158
+ * `variantId` key, bumps the set's `$version`, writes the file back, runs the
159
+ * SD build, and returns a {@link VariantChangeset}.
160
+ *
161
+ * @param {AddVariantOptions} options
162
+ * @returns {Promise<VariantChangeset>}
163
+ */
164
+ export const addVariant = async (options) => {
165
+ const { componentJsonPath, variantId, fromVariant, sdConfigDir } = options
166
+
167
+ // 1. Read + parse.
168
+ const raw = readFileSync(componentJsonPath, 'utf-8')
169
+ const parsed = JSON.parse(raw)
170
+
171
+ // 2. Resolve keys.
172
+ const componentId = variantId.split('.')[0]
173
+ const newKey = variantId.split('.').slice(1).join('.')
174
+ const fromKey = fromVariant.split('.').slice(1).join('.')
175
+
176
+ // 3. Find component object.
177
+ const componentObj = parsed[componentId]
178
+ if (!componentObj || typeof componentObj !== 'object') {
179
+ throw new Error(
180
+ `addVariant: component "${componentId}" not found in ${componentJsonPath}`,
181
+ )
182
+ }
183
+
184
+ // 4. Find source slice.
185
+ const fromSlice = componentObj[fromKey]
186
+ if (!fromSlice || typeof fromSlice !== 'object') {
187
+ throw new Error(
188
+ `addVariant: source variant "${fromKey}" not found in component "${componentId}"`,
189
+ )
190
+ }
191
+
192
+ // 5. Deep-clone into new key.
193
+ componentObj[newKey] = JSON.parse(JSON.stringify(fromSlice))
194
+
195
+ // 6. Bump $version.
196
+ const prevVersion = parsed.$version ?? '1.0.0'
197
+ const newVersion = bumpVersion(String(prevVersion))
198
+ parsed.$version = newVersion
199
+
200
+ // 7. Write back.
201
+ writeFileSync(componentJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8')
202
+
203
+ // 8. Run SD build.
204
+ await runSdBuild(sdConfigDir)
205
+
206
+ // 9. Collect token ids from the cloned slice.
207
+ const tokenIds = flattenVariantIds(componentId, newKey, componentObj[newKey])
208
+
209
+ return { action: 'add', variantId, tokenIds, newVersion }
210
+ }
211
+
212
+ /**
213
+ * Mark every leaf token in a component variant as deprecated.
214
+ *
215
+ * Walks the `variantId` slice, sets `$deprecated = true` and
216
+ * `$extensions.sorb.replacedBy` on every leaf, bumps `$version`, writes the
217
+ * file back, runs the SD build, and returns a {@link VariantChangeset}.
218
+ *
219
+ * @param {DeprecateVariantOptions} options
220
+ * @returns {Promise<VariantChangeset>}
221
+ */
222
+ export const deprecateVariant = async (options) => {
223
+ const { componentJsonPath, variantId, replacedBy, sdConfigDir } = options
224
+
225
+ // 1. Read + parse.
226
+ const raw = readFileSync(componentJsonPath, 'utf-8')
227
+ const parsed = JSON.parse(raw)
228
+
229
+ // 2. Resolve keys.
230
+ const componentId = variantId.split('.')[0]
231
+ const variantKey = variantId.split('.').slice(1).join('.')
232
+
233
+ // 3. Find variant slice (error if absent).
234
+ const componentObj = parsed[componentId]
235
+ if (!componentObj || typeof componentObj !== 'object') {
236
+ throw new Error(
237
+ `deprecateVariant: component "${componentId}" not found in ${componentJsonPath}`,
238
+ )
239
+ }
240
+ const variantSlice = componentObj[variantKey]
241
+ if (!variantSlice || typeof variantSlice !== 'object') {
242
+ throw new Error(
243
+ `deprecateVariant: variant "${variantKey}" not found in component "${componentId}"`,
244
+ )
245
+ }
246
+
247
+ // 4. Walk the slice; mark every leaf node.
248
+ const tokenIds = []
249
+
250
+ const walk = (node, path) => {
251
+ if (node === null || typeof node !== 'object') return
252
+ if ('$value' in node) {
253
+ node.$deprecated = true
254
+ node.$extensions = { sorb: { replacedBy } }
255
+ tokenIds.push([componentId, variantKey, ...path].join('.'))
256
+ return
257
+ }
258
+ for (const key of Object.keys(node)) {
259
+ if (key.startsWith('$')) continue
260
+ walk(node[key], [...path, key])
261
+ }
262
+ }
263
+
264
+ walk(variantSlice, [])
265
+
266
+ // 5. Bump $version.
267
+ const prevVersion = parsed.$version ?? '1.0.0'
268
+ const newVersion = bumpVersion(String(prevVersion))
269
+ parsed.$version = newVersion
270
+
271
+ // 6. Write back.
272
+ writeFileSync(componentJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8')
273
+
274
+ // 7. Run SD build.
275
+ await runSdBuild(sdConfigDir)
276
+
277
+ // 8. Return changeset.
278
+ return { action: 'deprecate', variantId, tokenIds, newVersion }
279
+ }
@@ -0,0 +1,129 @@
1
+ // Tests for the variants.js pure helpers.
2
+ // Run: node --test src/variants.test.js
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import {
6
+ bumpVersion,
7
+ flattenVariantIds,
8
+ cloneVariantSlice,
9
+ applyDeprecation,
10
+ } from './variants.js'
11
+
12
+ // ─── In-memory button component token matrix ─────────────────────────────────
13
+
14
+ const BUTTON_PRIMARY_SLICE = {
15
+ bg: {
16
+ default: { $value: '#0f65ef', $type: 'color' },
17
+ hover: { $value: '#0a4fc4', $type: 'color' },
18
+ },
19
+ text: {
20
+ default: { $value: '#ffffff', $type: 'color' },
21
+ },
22
+ border: {
23
+ default: { $value: '#0f65ef', $type: 'color' },
24
+ },
25
+ }
26
+
27
+ const BUTTON_SINGLE_LEVEL_SLICE = {
28
+ radius: { $value: '4px', $type: 'dimension' },
29
+ }
30
+
31
+ // ─── flattenVariantIds ────────────────────────────────────────────────────────
32
+
33
+ test('flattenVariantIds — button.primary slice produces expected ids', () => {
34
+ const ids = flattenVariantIds('button', 'primary', BUTTON_PRIMARY_SLICE)
35
+ assert.deepEqual(ids.sort(), [
36
+ 'button.primary.bg.default',
37
+ 'button.primary.bg.hover',
38
+ 'button.primary.border.default',
39
+ 'button.primary.text.default',
40
+ ])
41
+ })
42
+
43
+ test('flattenVariantIds — single-level slice produces correct ids', () => {
44
+ const ids = flattenVariantIds('button', 'base', BUTTON_SINGLE_LEVEL_SLICE)
45
+ assert.deepEqual(ids, ['button.base.radius'])
46
+ })
47
+
48
+ test('flattenVariantIds — skips $-prefixed metadata keys', () => {
49
+ const sliceWithMeta = {
50
+ $description: 'ignored',
51
+ bg: { $value: '#000000', $type: 'color' },
52
+ }
53
+ const ids = flattenVariantIds('button', 'ghost', sliceWithMeta)
54
+ assert.deepEqual(ids, ['button.ghost.bg'])
55
+ })
56
+
57
+ // ─── bumpVersion ─────────────────────────────────────────────────────────────
58
+
59
+ test('bumpVersion — "1.0.0" bumps patch to "1.0.1"', () => {
60
+ assert.equal(bumpVersion('1.0.0'), '1.0.1')
61
+ })
62
+
63
+ test('bumpVersion — "2.3.7" bumps patch to "2.3.8"', () => {
64
+ assert.equal(bumpVersion('2.3.7'), '2.3.8')
65
+ })
66
+
67
+ test('bumpVersion — malformed string falls back to "1.0.1"', () => {
68
+ assert.equal(bumpVersion('not-a-semver'), '1.0.1')
69
+ })
70
+
71
+ test('bumpVersion — too few parts falls back to "1.0.1"', () => {
72
+ assert.equal(bumpVersion('1.0'), '1.0.1')
73
+ })
74
+
75
+ test('bumpVersion — non-string input falls back to "1.0.1"', () => {
76
+ assert.equal(bumpVersion(null), '1.0.1')
77
+ assert.equal(bumpVersion(undefined), '1.0.1')
78
+ assert.equal(bumpVersion(42), '1.0.1')
79
+ })
80
+
81
+ // ─── cloneVariantSlice ────────────────────────────────────────────────────────
82
+
83
+ test('cloneVariantSlice — returns a deep copy, not the same reference', () => {
84
+ const clone = cloneVariantSlice(BUTTON_PRIMARY_SLICE)
85
+ assert.deepEqual(clone, BUTTON_PRIMARY_SLICE)
86
+ assert.notEqual(clone, BUTTON_PRIMARY_SLICE)
87
+ assert.notEqual(clone.bg, BUTTON_PRIMARY_SLICE.bg)
88
+ assert.notEqual(clone.bg.default, BUTTON_PRIMARY_SLICE.bg.default)
89
+ })
90
+
91
+ test('cloneVariantSlice — mutation of clone does not affect original', () => {
92
+ const clone = cloneVariantSlice(BUTTON_PRIMARY_SLICE)
93
+ clone.bg.default.$value = 'mutated'
94
+ assert.equal(BUTTON_PRIMARY_SLICE.bg.default.$value, '#0f65ef')
95
+ })
96
+
97
+ // ─── applyDeprecation ─────────────────────────────────────────────────────────
98
+
99
+ test('applyDeprecation — all leaf nodes get $deprecated:true', () => {
100
+ const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
101
+ assert.equal(result.bg.default.$deprecated, true)
102
+ assert.equal(result.bg.hover.$deprecated, true)
103
+ assert.equal(result.text.default.$deprecated, true)
104
+ assert.equal(result.border.default.$deprecated, true)
105
+ })
106
+
107
+ test('applyDeprecation — all leaf nodes get $extensions.sorb.replacedBy set', () => {
108
+ const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
109
+ assert.equal(result.bg.default.$extensions.sorb.replacedBy, 'button.primary-v2')
110
+ assert.equal(result.bg.hover.$extensions.sorb.replacedBy, 'button.primary-v2')
111
+ assert.equal(result.text.default.$extensions.sorb.replacedBy, 'button.primary-v2')
112
+ assert.equal(result.border.default.$extensions.sorb.replacedBy, 'button.primary-v2')
113
+ })
114
+
115
+ test('applyDeprecation — does not mutate the original slice', () => {
116
+ const original = {
117
+ bg: { default: { $value: '#0f65ef', $type: 'color' } },
118
+ }
119
+ applyDeprecation(original, 'button.new')
120
+ assert.equal(original.bg.default.$deprecated, undefined)
121
+ assert.equal(original.bg.default.$extensions, undefined)
122
+ })
123
+
124
+ test('applyDeprecation — skips intermediate (non-leaf) nodes', () => {
125
+ const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
126
+ // intermediate nodes should not have $deprecated or $extensions set
127
+ assert.equal(result.bg.$deprecated, undefined)
128
+ assert.equal(result.$deprecated, undefined)
129
+ })