adapt-authoring-adaptframework 3.1.0 → 3.1.2
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/lib/AdaptFrameworkBuild.js +7 -4
- package/lib/AdaptFrameworkImport.js +11 -4
- package/lib/BuildCache.js +11 -11
- package/lib/utils/computeVarsHash.js +29 -0
- package/lib/utils/prebuildCache.js +6 -3
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/tests/AdaptFrameworkImport.spec.js +21 -2
- package/tests/BuildCache.spec.js +19 -8
- package/tests/utils-computeVarsHash.spec.js +70 -0
|
@@ -3,7 +3,7 @@ import { App, Hook, ensureDir, writeJson } from 'adapt-authoring-core'
|
|
|
3
3
|
import { parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
4
|
import { createWriteStream } from 'node:fs'
|
|
5
5
|
import AdaptCli from 'adapt-cli'
|
|
6
|
-
import { log, logDir, logMemory, copyFrameworkSource, generateLanguageManifest, applyBuildReplacements } from './utils.js'
|
|
6
|
+
import { log, logDir, logMemory, copyFrameworkSource, generateLanguageManifest, applyBuildReplacements, computeVarsHash } from './utils.js'
|
|
7
7
|
import BuildCache from './BuildCache.js'
|
|
8
8
|
import fs from 'node:fs/promises'
|
|
9
9
|
import path from 'upath'
|
|
@@ -200,6 +200,9 @@ class AdaptFrameworkBuild {
|
|
|
200
200
|
|
|
201
201
|
await this.loadCourseData()
|
|
202
202
|
|
|
203
|
+
// must hash before preBuildHook/applySchemaDefaults mutate course.data
|
|
204
|
+
const varsHash = this.isPreview ? computeVarsHash(this.courseData.course.data) : null
|
|
205
|
+
|
|
203
206
|
// Check for cached preview build
|
|
204
207
|
if (this.isPreview && !contentOnly) {
|
|
205
208
|
const cache = new BuildCache(path.join(framework.getConfig('buildDir'), 'prebuilt-cache'))
|
|
@@ -207,8 +210,8 @@ class AdaptFrameworkBuild {
|
|
|
207
210
|
const theme = this.courseData.config.data._theme
|
|
208
211
|
const menu = this.courseData.config.data._menu
|
|
209
212
|
|
|
210
|
-
if (await cache.has(pluginHash, theme, menu)) {
|
|
211
|
-
await cache.restore(pluginHash, theme, menu, this.buildDir)
|
|
213
|
+
if (await cache.has(pluginHash, theme, menu, varsHash)) {
|
|
214
|
+
await cache.restore(pluginHash, theme, menu, varsHash, this.buildDir)
|
|
212
215
|
await this.applySchemaDefaults()
|
|
213
216
|
await this.copyAssets()
|
|
214
217
|
await this.preBuildHook.invoke(this)
|
|
@@ -278,7 +281,7 @@ class AdaptFrameworkBuild {
|
|
|
278
281
|
const theme = this.courseData.config.data._theme
|
|
279
282
|
const menu = this.courseData.config.data._menu
|
|
280
283
|
try {
|
|
281
|
-
await cache.populate(this.buildDir, pluginHash, theme, menu)
|
|
284
|
+
await cache.populate(this.buildDir, pluginHash, theme, menu, varsHash)
|
|
282
285
|
} catch (e) {
|
|
283
286
|
log('warn', 'CACHE', `failed to populate prebuilt cache: ${e.message}`)
|
|
284
287
|
}
|
|
@@ -169,6 +169,11 @@ class AdaptFrameworkImport {
|
|
|
169
169
|
* @type {Array<String>}
|
|
170
170
|
*/
|
|
171
171
|
this.newTagIds = []
|
|
172
|
+
/**
|
|
173
|
+
* Array of asset IDs newly created during import for rollback. Excludes assets de-duplicated to existing records.
|
|
174
|
+
* @type {Array<String>}
|
|
175
|
+
*/
|
|
176
|
+
this.newAssetIds = []
|
|
172
177
|
/**
|
|
173
178
|
* Contains non-fatal infomation messages regarding import status which can be return as response data. Fatal errors are thrown in the usual way.
|
|
174
179
|
* @type {Object}
|
|
@@ -595,7 +600,9 @@ class AdaptFrameworkImport {
|
|
|
595
600
|
})
|
|
596
601
|
// store the asset _id so we can map it to the old path later
|
|
597
602
|
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
598
|
-
|
|
603
|
+
const assetId = asset._id.toString()
|
|
604
|
+
this.assetMap[resolved] = assetId
|
|
605
|
+
this.newAssetIds.push(assetId)
|
|
599
606
|
} catch (e) {
|
|
600
607
|
if (e.code === 'DUPLICATE_ASSET') {
|
|
601
608
|
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
@@ -902,9 +909,9 @@ class AdaptFrameworkImport {
|
|
|
902
909
|
if (Object.keys(this.updatedContentPlugins).length) {
|
|
903
910
|
tasks.push(this.restoreUpdatedPlugins())
|
|
904
911
|
}
|
|
905
|
-
// Delete
|
|
906
|
-
if (this.assets) {
|
|
907
|
-
tasks.push(...
|
|
912
|
+
// Delete newly created assets (skip de-duplicated assets which point to pre-existing records)
|
|
913
|
+
if (this.assets && this.newAssetIds.length) {
|
|
914
|
+
tasks.push(...this.newAssetIds.map(id =>
|
|
908
915
|
this.assets.delete({ _id: id })
|
|
909
916
|
.catch(e => log('warn', `failed to delete asset '${id}'`, e))
|
|
910
917
|
))
|
package/lib/BuildCache.js
CHANGED
|
@@ -6,7 +6,7 @@ import { log } from './utils/log.js'
|
|
|
6
6
|
const SKIP_ENTRIES = new Set(['course'])
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu).
|
|
9
|
+
* Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu, varsHash).
|
|
10
10
|
* One instance per cache root; methods are stateless beyond the root path.
|
|
11
11
|
*/
|
|
12
12
|
class BuildCache {
|
|
@@ -20,16 +20,16 @@ class BuildCache {
|
|
|
20
20
|
/**
|
|
21
21
|
* @returns {String} The cache directory path for the given combo
|
|
22
22
|
*/
|
|
23
|
-
getPath (pluginHash, theme, menu) {
|
|
24
|
-
return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}`)
|
|
23
|
+
getPath (pluginHash, theme, menu, varsHash) {
|
|
24
|
+
return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}_${varsHash}`)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* @returns {Promise<Boolean>} Whether a cached build exists for the given combo
|
|
29
29
|
*/
|
|
30
|
-
async has (pluginHash, theme, menu) {
|
|
30
|
+
async has (pluginHash, theme, menu, varsHash) {
|
|
31
31
|
try {
|
|
32
|
-
await fs.access(this.getPath(pluginHash, theme, menu))
|
|
32
|
+
await fs.access(this.getPath(pluginHash, theme, menu, varsHash))
|
|
33
33
|
return true
|
|
34
34
|
} catch {
|
|
35
35
|
return false
|
|
@@ -41,8 +41,8 @@ class BuildCache {
|
|
|
41
41
|
* Uses a temp dir + atomic rename for parallel safety.
|
|
42
42
|
* @param {String} buildOutputDir The build output directory
|
|
43
43
|
*/
|
|
44
|
-
async populate (buildOutputDir, pluginHash, theme, menu) {
|
|
45
|
-
const cacheDir = this.getPath(pluginHash, theme, menu)
|
|
44
|
+
async populate (buildOutputDir, pluginHash, theme, menu, varsHash) {
|
|
45
|
+
const cacheDir = this.getPath(pluginHash, theme, menu, varsHash)
|
|
46
46
|
await fs.mkdir(this.cacheRoot, { recursive: true })
|
|
47
47
|
|
|
48
48
|
const tmpDir = `${cacheDir}_tmp_${Date.now()}`
|
|
@@ -54,7 +54,7 @@ class BuildCache {
|
|
|
54
54
|
await copyEntry(path.join(buildOutputDir, entry), path.join(tmpDir, entry))
|
|
55
55
|
}
|
|
56
56
|
await safeRename(tmpDir, cacheDir)
|
|
57
|
-
log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
|
|
57
|
+
log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu}, vars=${varsHash})`)
|
|
58
58
|
} catch (e) {
|
|
59
59
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
60
60
|
throw e
|
|
@@ -65,10 +65,10 @@ class BuildCache {
|
|
|
65
65
|
* Copies cached artifacts to a build directory.
|
|
66
66
|
* @param {String} destDir Destination build directory
|
|
67
67
|
*/
|
|
68
|
-
async restore (pluginHash, theme, menu, destDir) {
|
|
68
|
+
async restore (pluginHash, theme, menu, varsHash, destDir) {
|
|
69
69
|
await fs.mkdir(destDir, { recursive: true })
|
|
70
|
-
await fs.cp(this.getPath(pluginHash, theme, menu), destDir, { recursive: true })
|
|
71
|
-
log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
|
|
70
|
+
await fs.cp(this.getPath(pluginHash, theme, menu, varsHash), destDir, { recursive: true })
|
|
71
|
+
log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu}, vars=${varsHash})`)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialises a value to JSON with object keys sorted (arrays preserve order, undefined → null)
|
|
5
|
+
* @param {*} value
|
|
6
|
+
* @return {String}
|
|
7
|
+
*/
|
|
8
|
+
export function canonicalJson (value) {
|
|
9
|
+
if (value === undefined || value === null) return 'null'
|
|
10
|
+
if (typeof value !== 'object') return JSON.stringify(value)
|
|
11
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
|
|
12
|
+
const keys = Object.keys(value).sort()
|
|
13
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hashes per-course LESS inputs (themeVariables + customStyle) for the cache key
|
|
18
|
+
* @param {Object} [data]
|
|
19
|
+
* @param {Object} [data.themeVariables]
|
|
20
|
+
* @param {String} [data.customStyle]
|
|
21
|
+
* @return {String} 12-char hex hash
|
|
22
|
+
*/
|
|
23
|
+
export function computeVarsHash ({ themeVariables, customStyle } = {}) {
|
|
24
|
+
const input = canonicalJson({
|
|
25
|
+
themeVariables: themeVariables ?? null,
|
|
26
|
+
customStyle: customStyle ?? null
|
|
27
|
+
})
|
|
28
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 12)
|
|
29
|
+
}
|
|
@@ -5,8 +5,11 @@ import path from 'upath'
|
|
|
5
5
|
import { copyFrameworkSource } from './copyFrameworkSource.js'
|
|
6
6
|
import BuildCache from '../BuildCache.js'
|
|
7
7
|
import { computePluginHash } from './computePluginHash.js'
|
|
8
|
+
import { computeVarsHash } from './computeVarsHash.js'
|
|
8
9
|
import { log } from './log.js'
|
|
9
10
|
|
|
11
|
+
const DEFAULT_VARS_HASH = computeVarsHash()
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Eagerly populates the prebuilt cache for every (theme, menu) combination
|
|
12
15
|
* of installed plugins. Iterates serially: each iteration runs a full grunt
|
|
@@ -54,7 +57,7 @@ export async function prebuildCache ({ buildDir, frameworkDir }) {
|
|
|
54
57
|
async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugins }) {
|
|
55
58
|
const app = App.instance
|
|
56
59
|
|
|
57
|
-
if (await cache.has(pluginHash, theme.name, menu.name)) {
|
|
60
|
+
if (await cache.has(pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)) {
|
|
58
61
|
log('info', 'CACHE', `skipping cached combo theme=${theme.name} menu=${menu.name}`)
|
|
59
62
|
return
|
|
60
63
|
}
|
|
@@ -101,8 +104,8 @@ async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugi
|
|
|
101
104
|
logger: { log: (...args) => app.logger.log('debug', 'adapt-cli', ...args) }
|
|
102
105
|
})
|
|
103
106
|
|
|
104
|
-
if (!await cache.has(pluginHash, theme.name, menu.name)) {
|
|
105
|
-
await cache.populate(outputDir, pluginHash, theme.name, menu.name)
|
|
107
|
+
if (!await cache.has(pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)) {
|
|
108
|
+
await cache.populate(outputDir, pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)
|
|
106
109
|
}
|
|
107
110
|
} finally {
|
|
108
111
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
package/lib/utils.js
CHANGED
|
@@ -16,3 +16,4 @@ export { computePluginHash } from './utils/computePluginHash.js'
|
|
|
16
16
|
export { prebuildCache } from './utils/prebuildCache.js'
|
|
17
17
|
export { generateLanguageManifest } from './utils/generateLanguageManifest.js'
|
|
18
18
|
export { applyBuildReplacements } from './utils/applyBuildReplacements.js'
|
|
19
|
+
export { computeVarsHash } from './utils/computeVarsHash.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-adaptframework",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Adapt framework integration for the Adapt authoring tool",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -404,6 +404,7 @@ describe('AdaptFrameworkImport', () => {
|
|
|
404
404
|
newContentPlugins: {},
|
|
405
405
|
updatedContentPlugins: {},
|
|
406
406
|
assetMap: {},
|
|
407
|
+
newAssetIds: [],
|
|
407
408
|
newTagIds: [],
|
|
408
409
|
contentJson: { course: {} },
|
|
409
410
|
idMap: {},
|
|
@@ -438,12 +439,29 @@ describe('AdaptFrameworkImport', () => {
|
|
|
438
439
|
assetMap: {
|
|
439
440
|
'course/en/assets/logo.png': 'a1',
|
|
440
441
|
'course/en/assets/bg.jpg': 'a2'
|
|
441
|
-
}
|
|
442
|
+
},
|
|
443
|
+
newAssetIds: ['a1', 'a2']
|
|
442
444
|
})
|
|
443
445
|
await rollback.call(ctx)
|
|
444
446
|
assert.deepEqual(deleted.sort(), ['a1', 'a2'])
|
|
445
447
|
})
|
|
446
448
|
|
|
449
|
+
it('should not delete de-duplicated assets that point to pre-existing records', async () => {
|
|
450
|
+
const deleted = []
|
|
451
|
+
const ctx = makeRollbackCtx({
|
|
452
|
+
assets: {
|
|
453
|
+
delete: async ({ _id }) => deleted.push(_id)
|
|
454
|
+
},
|
|
455
|
+
assetMap: {
|
|
456
|
+
'course/en/assets/new.png': 'a1',
|
|
457
|
+
'course/en/assets/existing.png': 'a2-existing'
|
|
458
|
+
},
|
|
459
|
+
newAssetIds: ['a1']
|
|
460
|
+
})
|
|
461
|
+
await rollback.call(ctx)
|
|
462
|
+
assert.deepEqual(deleted, ['a1'])
|
|
463
|
+
})
|
|
464
|
+
|
|
447
465
|
it('should delete course content on rollback', async () => {
|
|
448
466
|
const contentDeleted = []
|
|
449
467
|
const ctx = makeRollbackCtx({
|
|
@@ -499,7 +517,8 @@ describe('AdaptFrameworkImport', () => {
|
|
|
499
517
|
'path/a.png': 'a1',
|
|
500
518
|
'path/b.png': 'a2',
|
|
501
519
|
'path/c.png': 'a3'
|
|
502
|
-
}
|
|
520
|
+
},
|
|
521
|
+
newAssetIds: ['a1', 'a2', 'a3']
|
|
503
522
|
})
|
|
504
523
|
await rollback.call(ctx)
|
|
505
524
|
assert.deepEqual(deleted.sort(), ['a2', 'a3'])
|
package/tests/BuildCache.spec.js
CHANGED
|
@@ -34,18 +34,29 @@ describe('BuildCache', () => {
|
|
|
34
34
|
|
|
35
35
|
describe('getPath()', () => {
|
|
36
36
|
it('returns one combo-keyed directory path', () => {
|
|
37
|
-
assert.equal(cache.getPath('abc123', 'vanilla', 'boxMenu'), upath.join(cacheRoot, '
|
|
37
|
+
assert.equal(cache.getPath('abc123', 'vanilla', 'boxMenu', 'vhash'), upath.join(cacheRoot, 'abc123_vanilla_boxMenu_vhash'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns distinct paths for different varsHash', () => {
|
|
41
|
+
const a = cache.getPath('abc123', 'vanilla', 'boxMenu', 'aaa')
|
|
42
|
+
const b = cache.getPath('abc123', 'vanilla', 'boxMenu', 'bbb')
|
|
43
|
+
assert.notEqual(a, b)
|
|
38
44
|
})
|
|
39
45
|
})
|
|
40
46
|
|
|
41
47
|
describe('has()', () => {
|
|
42
48
|
it('returns false when cache does not exist', async () => {
|
|
43
|
-
assert.equal(await cache.has('hash1', 'theme', 'menu'), false)
|
|
49
|
+
assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhash'), false)
|
|
44
50
|
})
|
|
45
51
|
|
|
46
52
|
it('returns true when the combo dir exists', async () => {
|
|
47
|
-
await fs.mkdir(cache.getPath('hash1', 'theme', 'menu'), { recursive: true })
|
|
48
|
-
assert.equal(await cache.has('hash1', 'theme', 'menu'), true)
|
|
53
|
+
await fs.mkdir(cache.getPath('hash1', 'theme', 'menu', 'vhash'), { recursive: true })
|
|
54
|
+
assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhash'), true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns false when only a different varsHash exists', async () => {
|
|
58
|
+
await fs.mkdir(cache.getPath('hash1', 'theme', 'menu', 'vhashA'), { recursive: true })
|
|
59
|
+
assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhashB'), false)
|
|
49
60
|
})
|
|
50
61
|
})
|
|
51
62
|
|
|
@@ -65,9 +76,9 @@ describe('BuildCache', () => {
|
|
|
65
76
|
await fs.mkdir(path.join(buildDir, 'course', 'en'), { recursive: true })
|
|
66
77
|
await fs.writeFile(path.join(buildDir, 'course', 'en', 'course.json'), '{}')
|
|
67
78
|
|
|
68
|
-
await cache.populate(buildDir, 'hash1', 'theme', 'menu')
|
|
79
|
+
await cache.populate(buildDir, 'hash1', 'theme', 'menu', 'vhash')
|
|
69
80
|
|
|
70
|
-
const cacheDir = cache.getPath('hash1', 'theme', 'menu')
|
|
81
|
+
const cacheDir = cache.getPath('hash1', 'theme', 'menu', 'vhash')
|
|
71
82
|
assert.equal(await fs.readFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'js-content')
|
|
72
83
|
assert.equal(await fs.readFile(path.join(cacheDir, 'index.html'), 'utf8'), '<html></html>')
|
|
73
84
|
assert.equal(await fs.readFile(path.join(cacheDir, 'adapt.css'), 'utf8'), 'css-content')
|
|
@@ -78,13 +89,13 @@ describe('BuildCache', () => {
|
|
|
78
89
|
|
|
79
90
|
describe('restore()', () => {
|
|
80
91
|
it('copies cached artifacts to destination', async () => {
|
|
81
|
-
const cacheDir = cache.getPath('hash1', 'theme', 'menu')
|
|
92
|
+
const cacheDir = cache.getPath('hash1', 'theme', 'menu', 'vhash')
|
|
82
93
|
await fs.mkdir(path.join(cacheDir, 'adapt', 'js'), { recursive: true })
|
|
83
94
|
await fs.writeFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'cached-js')
|
|
84
95
|
await fs.writeFile(path.join(cacheDir, 'adapt.css'), 'cached-css')
|
|
85
96
|
|
|
86
97
|
const destDir = path.join(tmpDir, 'restored')
|
|
87
|
-
await cache.restore('hash1', 'theme', 'menu', destDir)
|
|
98
|
+
await cache.restore('hash1', 'theme', 'menu', 'vhash', destDir)
|
|
88
99
|
|
|
89
100
|
assert.equal(await fs.readFile(path.join(destDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'cached-js')
|
|
90
101
|
assert.equal(await fs.readFile(path.join(destDir, 'adapt.css'), 'utf8'), 'cached-css')
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { canonicalJson, computeVarsHash } from '../lib/utils/computeVarsHash.js'
|
|
4
|
+
|
|
5
|
+
describe('canonicalJson()', () => {
|
|
6
|
+
const cases = [
|
|
7
|
+
{ name: 'primitive string', input: 'abc', expected: '"abc"' },
|
|
8
|
+
{ name: 'primitive number', input: 42, expected: '42' },
|
|
9
|
+
{ name: 'primitive boolean', input: true, expected: 'true' },
|
|
10
|
+
{ name: 'null', input: null, expected: 'null' },
|
|
11
|
+
{ name: 'undefined coerces to null', input: undefined, expected: 'null' },
|
|
12
|
+
{ name: 'empty object', input: {}, expected: '{}' },
|
|
13
|
+
{ name: 'empty array', input: [], expected: '[]' },
|
|
14
|
+
{ name: 'array preserves order', input: [3, 1, 2], expected: '[3,1,2]' },
|
|
15
|
+
{ name: 'sorts top-level keys', input: { b: 1, a: 2 }, expected: '{"a":2,"b":1}' },
|
|
16
|
+
{ name: 'sorts nested keys', input: { x: { d: 1, c: 2 } }, expected: '{"x":{"c":2,"d":1}}' },
|
|
17
|
+
{ name: 'mixed nesting', input: { a: [{ z: 1, y: 2 }] }, expected: '{"a":[{"y":2,"z":1}]}' }
|
|
18
|
+
]
|
|
19
|
+
for (const { name, input, expected } of cases) {
|
|
20
|
+
it(name, () => assert.equal(canonicalJson(input), expected))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('produces identical output for objects with different key insertion order', () => {
|
|
24
|
+
const a = { _colors: { primary: 'red', secondary: 'blue' }, _font: { family: 'serif' } }
|
|
25
|
+
const b = { _font: { family: 'serif' }, _colors: { secondary: 'blue', primary: 'red' } }
|
|
26
|
+
assert.equal(canonicalJson(a), canonicalJson(b))
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('computeVarsHash()', () => {
|
|
31
|
+
it('returns a 12-character hex string', () => {
|
|
32
|
+
assert.match(computeVarsHash({ themeVariables: { a: 1 } }), /^[0-9a-f]{12}$/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is deterministic for identical inputs', () => {
|
|
36
|
+
const data = { themeVariables: { _colors: { primary: 'green' } }, customStyle: '.foo { color: red; }' }
|
|
37
|
+
assert.equal(computeVarsHash(data), computeVarsHash(data))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('treats missing, undefined, null, and empty equivalently', () => {
|
|
41
|
+
const empty = computeVarsHash()
|
|
42
|
+
assert.equal(computeVarsHash({}), empty)
|
|
43
|
+
assert.equal(computeVarsHash({ themeVariables: null, customStyle: null }), empty)
|
|
44
|
+
assert.equal(computeVarsHash({ themeVariables: undefined, customStyle: undefined }), empty)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is insensitive to key order within themeVariables', () => {
|
|
48
|
+
const a = computeVarsHash({ themeVariables: { _colors: { primary: 'red', secondary: 'blue' } } })
|
|
49
|
+
const b = computeVarsHash({ themeVariables: { _colors: { secondary: 'blue', primary: 'red' } } })
|
|
50
|
+
assert.equal(a, b)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('differs when themeVariables differ', () => {
|
|
54
|
+
const a = computeVarsHash({ themeVariables: { _colors: { primary: 'red' } } })
|
|
55
|
+
const b = computeVarsHash({ themeVariables: { _colors: { primary: 'green' } } })
|
|
56
|
+
assert.notEqual(a, b)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('differs when customStyle differs', () => {
|
|
60
|
+
const a = computeVarsHash({ customStyle: '.foo { color: red; }' })
|
|
61
|
+
const b = computeVarsHash({ customStyle: '.foo { color: green; }' })
|
|
62
|
+
assert.notEqual(a, b)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('is sensitive to array order (font import precedence)', () => {
|
|
66
|
+
const a = computeVarsHash({ themeVariables: { _font: { _externalFonts: ['a.css', 'b.css'] } } })
|
|
67
|
+
const b = computeVarsHash({ themeVariables: { _font: { _externalFonts: ['b.css', 'a.css'] } } })
|
|
68
|
+
assert.notEqual(a, b)
|
|
69
|
+
})
|
|
70
|
+
})
|