adapt-authoring-adaptframework 2.5.6 → 3.0.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/.github/workflows/releases.yml +2 -1
- package/conf/config.schema.json +5 -0
- package/index.js +1 -1
- package/lib/AdaptFrameworkBuild.js +97 -47
- package/lib/AdaptFrameworkImport.js +87 -80
- package/lib/AdaptFrameworkModule.js +87 -4
- package/lib/BuildCache.js +105 -0
- package/lib/handlers.js +3 -2
- package/lib/migrations/vanilla-background-styles.js +17 -0
- package/lib/utils/applyBuildReplacements.js +23 -0
- package/lib/utils/collectMigrationScripts.js +15 -0
- package/lib/utils/computePluginHash.js +14 -0
- package/lib/utils/copyFrameworkSource.js +2 -2
- package/lib/utils/generateLanguageManifest.js +9 -0
- package/lib/utils/log.js +5 -7
- package/lib/utils/migrateExistingCourses.js +87 -0
- package/lib/utils/prebuildCache.js +110 -0
- package/lib/utils/readFrameworkPluginVersions.js +21 -0
- package/lib/utils/runContentMigration.js +72 -0
- package/lib/utils.js +8 -0
- package/package.json +4 -5
- package/tests/AdaptFrameworkImport.spec.js +7 -13
- package/tests/BuildCache.spec.js +107 -0
- package/tests/utils-applyBuildReplacements.spec.js +57 -0
- package/tests/utils-collectMigrationScripts.spec.js +45 -0
- package/tests/utils-computePluginHash.spec.js +40 -0
- package/tests/utils-generateLanguageManifest.spec.js +27 -0
- package/tests/utils-migrateExistingCourses.spec.js +163 -0
- package/tests/utils-readFrameworkPluginVersions.spec.js +48 -0
- package/tests/utils-runContentMigration.spec.js +101 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { App, ensureDir } from 'adapt-authoring-core'
|
|
5
|
+
import { load, migrate, Journal, Logger } from 'adapt-migrations'
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs adapt-migrations on a content array. Shared by framework update, course import, and plugin update.
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {Array} options.content Flat array of content objects (course, config, contentObjects, etc.)
|
|
13
|
+
* @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before the update
|
|
14
|
+
* @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after the update
|
|
15
|
+
* @param {String[]} options.scripts Absolute paths to migration scripts
|
|
16
|
+
* @param {String} [options.cachePath] Optional cache path for adapt-migrations. If omitted, a unique dir under the app's tempDir is created and removed after migration — callers running concurrently MUST either omit this or pass a unique path per call, as adapt-migrations wipes the directory on entry.
|
|
17
|
+
* @returns {Promise<Array>} The migrated content array
|
|
18
|
+
*/
|
|
19
|
+
export async function runContentMigration ({ content, fromPlugins, toPlugins, scripts, cachePath }) {
|
|
20
|
+
const logger = Logger.getInstance()
|
|
21
|
+
|
|
22
|
+
let resolvedCachePath = cachePath
|
|
23
|
+
const usingEphemeralCache = !resolvedCachePath
|
|
24
|
+
if (usingEphemeralCache) {
|
|
25
|
+
const tempDir = App.instance.getConfig('tempDir')
|
|
26
|
+
const baseCacheDir = path.join(tempDir, 'migration-cache')
|
|
27
|
+
await ensureDir(baseCacheDir)
|
|
28
|
+
// Symlink node_modules at the base so cached migration scripts' bare
|
|
29
|
+
// `import 'adapt-migrations'` resolves via Node's upward walk. It must sit
|
|
30
|
+
// a level ABOVE the run dir, otherwise adapt-migrations's own `npm install`
|
|
31
|
+
// step (which runs in the run dir) wipes the symlink.
|
|
32
|
+
const sharedLink = path.join(baseCacheDir, 'node_modules')
|
|
33
|
+
if (!fs.existsSync(sharedLink)) {
|
|
34
|
+
const sharedNodeModules = path.dirname(path.dirname(require.resolve('adapt-migrations')))
|
|
35
|
+
try {
|
|
36
|
+
fs.symlinkSync(sharedNodeModules, sharedLink, 'dir')
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code !== 'EEXIST') throw err
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
resolvedCachePath = fs.mkdtempSync(path.join(baseCacheDir, 'run-'))
|
|
42
|
+
} else {
|
|
43
|
+
await ensureDir(resolvedCachePath)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await load({ scripts, cachePath: resolvedCachePath, logger })
|
|
48
|
+
|
|
49
|
+
const originalFromPlugins = JSON.parse(JSON.stringify(fromPlugins))
|
|
50
|
+
const journal = new Journal({
|
|
51
|
+
logger,
|
|
52
|
+
data: {
|
|
53
|
+
content,
|
|
54
|
+
fromPlugins,
|
|
55
|
+
originalFromPlugins,
|
|
56
|
+
toPlugins
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await migrate({ journal, logger })
|
|
61
|
+
|
|
62
|
+
return journal.data.content
|
|
63
|
+
} finally {
|
|
64
|
+
if (usingEphemeralCache) {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(resolvedCachePath, { recursive: true, force: true })
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.warn(`Failed to clean up migration cache at ${resolvedCachePath}: ${err.message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -7,3 +7,11 @@ export { retrieveBuildData } from './utils/retrieveBuildData.js'
|
|
|
7
7
|
export { getImportSummary } from './utils/getImportSummary.js'
|
|
8
8
|
export { slugifyTitle } from './utils/slugifyTitle.js'
|
|
9
9
|
export { copyFrameworkSource } from './utils/copyFrameworkSource.js'
|
|
10
|
+
export { readFrameworkPluginVersions } from './utils/readFrameworkPluginVersions.js'
|
|
11
|
+
export { collectMigrationScripts } from './utils/collectMigrationScripts.js'
|
|
12
|
+
export { runContentMigration } from './utils/runContentMigration.js'
|
|
13
|
+
export { migrateExistingCourses } from './utils/migrateExistingCourses.js'
|
|
14
|
+
export { computePluginHash } from './utils/computePluginHash.js'
|
|
15
|
+
export { prebuildCache } from './utils/prebuildCache.js'
|
|
16
|
+
export { generateLanguageManifest } from './utils/generateLanguageManifest.js'
|
|
17
|
+
export { applyBuildReplacements } from './utils/applyBuildReplacements.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-adaptframework",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
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",
|
|
@@ -11,15 +11,14 @@
|
|
|
11
11
|
"test": "node --test --test-force-exit --experimental-test-module-mocks 'tests/**/*.spec.js'"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"adapt-authoring-content": "^
|
|
14
|
+
"adapt-authoring-content": "^3.0.0",
|
|
15
15
|
"adapt-authoring-contentplugin": "^1.0.3",
|
|
16
|
-
"adapt-authoring-core": "^
|
|
17
|
-
"adapt-authoring-courseassets": "^1.0.3",
|
|
16
|
+
"adapt-authoring-core": "^3.0.0",
|
|
18
17
|
"adapt-authoring-coursetheme": "^1.0.2",
|
|
19
18
|
"adapt-authoring-mongodb": "^3.0.0",
|
|
20
19
|
"adapt-authoring-spoortracking": "^1.0.2",
|
|
21
20
|
"adapt-cli": "^3.3.3",
|
|
22
|
-
"adapt-
|
|
21
|
+
"adapt-migrations": "^1.4.0",
|
|
23
22
|
"bytes": "^3.1.2",
|
|
24
23
|
"fs-extra": "11.3.3",
|
|
25
24
|
"glob": "^13.0.0",
|
|
@@ -150,7 +150,7 @@ describe('AdaptFrameworkImport', () => {
|
|
|
150
150
|
|
|
151
151
|
describe('#resolveAssets()', () => {
|
|
152
152
|
function makeCtx (assetMap) {
|
|
153
|
-
const ctx = { assetMap }
|
|
153
|
+
const ctx = { assetMap, statusReport: { warn: [] } }
|
|
154
154
|
ctx.resolveAssets = AdaptFrameworkImport.prototype.resolveAssets.bind(ctx)
|
|
155
155
|
return ctx
|
|
156
156
|
}
|
|
@@ -207,14 +207,17 @@ describe('AdaptFrameworkImport', () => {
|
|
|
207
207
|
assert.equal('src' in data._graphic, false)
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
-
it('should
|
|
210
|
+
it('should drop unresolved asset refs and surface them in statusReport.warn', () => {
|
|
211
211
|
const ctx = makeCtx({})
|
|
212
212
|
const schema = makeSchema({
|
|
213
213
|
img: { _backboneForms: { type: 'Asset' } }
|
|
214
214
|
})
|
|
215
215
|
const data = { img: 'unknown/path.png' }
|
|
216
216
|
ctx.resolveAssets(schema, data)
|
|
217
|
-
assert.equal(data
|
|
217
|
+
assert.equal('img' in data, false)
|
|
218
|
+
assert.equal(ctx.statusReport.warn.length, 1)
|
|
219
|
+
assert.equal(ctx.statusReport.warn[0].code, 'UNRESOLVED_ASSET_REF')
|
|
220
|
+
assert.equal(ctx.statusReport.warn[0].data.path, 'unknown/path.png')
|
|
218
221
|
})
|
|
219
222
|
|
|
220
223
|
it('should recurse into nested properties', () => {
|
|
@@ -407,7 +410,6 @@ describe('AdaptFrameworkImport', () => {
|
|
|
407
410
|
contentplugin: null,
|
|
408
411
|
assets: null,
|
|
409
412
|
content: null,
|
|
410
|
-
courseassets: null,
|
|
411
413
|
...overrides
|
|
412
414
|
}
|
|
413
415
|
}
|
|
@@ -442,22 +444,17 @@ describe('AdaptFrameworkImport', () => {
|
|
|
442
444
|
assert.deepEqual(deleted.sort(), ['a1', 'a2'])
|
|
443
445
|
})
|
|
444
446
|
|
|
445
|
-
it('should delete course content
|
|
447
|
+
it('should delete course content on rollback', async () => {
|
|
446
448
|
const contentDeleted = []
|
|
447
|
-
const courseAssetsDeleted = []
|
|
448
449
|
const ctx = makeRollbackCtx({
|
|
449
450
|
content: {
|
|
450
451
|
deleteMany: async (query) => contentDeleted.push(query)
|
|
451
452
|
},
|
|
452
|
-
courseassets: {
|
|
453
|
-
deleteMany: async (query) => courseAssetsDeleted.push(query)
|
|
454
|
-
},
|
|
455
453
|
contentJson: { course: { _id: 'oldCourseId' } },
|
|
456
454
|
idMap: { oldCourseId: '507f1f77bcf86cd799439011' }
|
|
457
455
|
})
|
|
458
456
|
await rollback.call(ctx)
|
|
459
457
|
assert.equal(contentDeleted.length, 1)
|
|
460
|
-
assert.equal(courseAssetsDeleted.length, 1)
|
|
461
458
|
})
|
|
462
459
|
|
|
463
460
|
it('should skip plugin uninstall when contentplugin is not available', async () => {
|
|
@@ -482,9 +479,6 @@ describe('AdaptFrameworkImport', () => {
|
|
|
482
479
|
content: {
|
|
483
480
|
deleteMany: async (query) => deleted.push(query)
|
|
484
481
|
},
|
|
485
|
-
courseassets: {
|
|
486
|
-
deleteMany: async (query) => deleted.push(query)
|
|
487
|
-
},
|
|
488
482
|
contentJson: { course: { _id: 'oldCourseId' } },
|
|
489
483
|
idMap: {} // no mapping exists
|
|
490
484
|
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { before, describe, it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import upath from 'upath'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
|
|
8
|
+
describe('BuildCache', () => {
|
|
9
|
+
let BuildCache
|
|
10
|
+
let tmpDir, cacheRoot, buildDir, cache
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
mock.module('../lib/utils/log.js', {
|
|
14
|
+
namedExports: {
|
|
15
|
+
log: () => {},
|
|
16
|
+
logDir: () => {},
|
|
17
|
+
logMemory: () => {}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
;({ default: BuildCache } = await import('../lib/BuildCache.js'))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aat-cache-test-'))
|
|
25
|
+
cacheRoot = path.join(tmpDir, 'prebuilt-cache')
|
|
26
|
+
buildDir = path.join(tmpDir, 'build')
|
|
27
|
+
await fs.mkdir(buildDir, { recursive: true })
|
|
28
|
+
cache = new BuildCache(cacheRoot)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('getPath()', () => {
|
|
36
|
+
it('returns one combo-keyed directory path', () => {
|
|
37
|
+
assert.equal(cache.getPath('abc123', 'vanilla', 'boxMenu'), upath.join(cacheRoot, 'abc123_vanilla_boxMenu'))
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('has()', () => {
|
|
42
|
+
it('returns false when cache does not exist', async () => {
|
|
43
|
+
assert.equal(await cache.has('hash1', 'theme', 'menu'), false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
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)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('populate()', () => {
|
|
53
|
+
it('caches all build entries except course/', async () => {
|
|
54
|
+
await fs.mkdir(path.join(buildDir, 'adapt', 'js'), { recursive: true })
|
|
55
|
+
await fs.writeFile(path.join(buildDir, 'adapt', 'js', 'adapt.min.js'), 'js-content')
|
|
56
|
+
await fs.writeFile(path.join(buildDir, 'adapt.css'), 'css-content')
|
|
57
|
+
await fs.writeFile(path.join(buildDir, 'adapt.css.map'), 'map-content')
|
|
58
|
+
await fs.mkdir(path.join(buildDir, 'fonts'), { recursive: true })
|
|
59
|
+
await fs.writeFile(path.join(buildDir, 'fonts', 'icon.woff2'), 'font-data')
|
|
60
|
+
await fs.writeFile(path.join(buildDir, 'index.html'), '<html></html>')
|
|
61
|
+
await fs.writeFile(path.join(buildDir, 'templates.js'), 'templates')
|
|
62
|
+
await fs.mkdir(path.join(buildDir, 'libraries'), { recursive: true })
|
|
63
|
+
await fs.writeFile(path.join(buildDir, 'libraries', 'modernizr.js'), 'lib')
|
|
64
|
+
// course/ should be skipped
|
|
65
|
+
await fs.mkdir(path.join(buildDir, 'course', 'en'), { recursive: true })
|
|
66
|
+
await fs.writeFile(path.join(buildDir, 'course', 'en', 'course.json'), '{}')
|
|
67
|
+
|
|
68
|
+
await cache.populate(buildDir, 'hash1', 'theme', 'menu')
|
|
69
|
+
|
|
70
|
+
const cacheDir = cache.getPath('hash1', 'theme', 'menu')
|
|
71
|
+
assert.equal(await fs.readFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'js-content')
|
|
72
|
+
assert.equal(await fs.readFile(path.join(cacheDir, 'index.html'), 'utf8'), '<html></html>')
|
|
73
|
+
assert.equal(await fs.readFile(path.join(cacheDir, 'adapt.css'), 'utf8'), 'css-content')
|
|
74
|
+
assert.equal(await fs.readFile(path.join(cacheDir, 'fonts', 'icon.woff2'), 'utf8'), 'font-data')
|
|
75
|
+
await assert.rejects(fs.access(path.join(cacheDir, 'course')), { code: 'ENOENT' })
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('restore()', () => {
|
|
80
|
+
it('copies cached artifacts to destination', async () => {
|
|
81
|
+
const cacheDir = cache.getPath('hash1', 'theme', 'menu')
|
|
82
|
+
await fs.mkdir(path.join(cacheDir, 'adapt', 'js'), { recursive: true })
|
|
83
|
+
await fs.writeFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'cached-js')
|
|
84
|
+
await fs.writeFile(path.join(cacheDir, 'adapt.css'), 'cached-css')
|
|
85
|
+
|
|
86
|
+
const destDir = path.join(tmpDir, 'restored')
|
|
87
|
+
await cache.restore('hash1', 'theme', 'menu', destDir)
|
|
88
|
+
|
|
89
|
+
assert.equal(await fs.readFile(path.join(destDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'cached-js')
|
|
90
|
+
assert.equal(await fs.readFile(path.join(destDir, 'adapt.css'), 'utf8'), 'cached-css')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('invalidate()', () => {
|
|
95
|
+
it('removes the cache directory', async () => {
|
|
96
|
+
await fs.mkdir(cacheRoot, { recursive: true })
|
|
97
|
+
await fs.writeFile(path.join(cacheRoot, 'test'), 'data')
|
|
98
|
+
await cache.invalidate()
|
|
99
|
+
await assert.rejects(fs.access(cacheRoot), { code: 'ENOENT' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does not throw when cache does not exist', async () => {
|
|
103
|
+
const missing = new BuildCache(path.join(tmpDir, 'nonexistent'))
|
|
104
|
+
await assert.doesNotReject(missing.invalidate())
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
import { applyBuildReplacements } from '../lib/utils/applyBuildReplacements.js'
|
|
8
|
+
|
|
9
|
+
describe('applyBuildReplacements()', () => {
|
|
10
|
+
let tmpDir
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aat-replace-test-'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should replace all @@placeholders in index.html', async () => {
|
|
21
|
+
const template = [
|
|
22
|
+
'<html lang="@@config._defaultLanguage" dir="@@config._defaultDirection">',
|
|
23
|
+
'<meta name="build.type" content="@@build.type">',
|
|
24
|
+
'<meta name="build.timestamp" content="@@build.timestamp">'
|
|
25
|
+
].join('\n')
|
|
26
|
+
await fs.writeFile(path.join(tmpDir, 'index.html'), template)
|
|
27
|
+
|
|
28
|
+
await applyBuildReplacements(tmpDir, {
|
|
29
|
+
defaultLanguage: 'fr',
|
|
30
|
+
defaultDirection: 'rtl',
|
|
31
|
+
buildType: 'development',
|
|
32
|
+
timestamp: 1234567890
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const result = await fs.readFile(path.join(tmpDir, 'index.html'), 'utf8')
|
|
36
|
+
assert.ok(result.includes('lang="fr"'))
|
|
37
|
+
assert.ok(result.includes('dir="rtl"'))
|
|
38
|
+
assert.ok(result.includes('content="development"'))
|
|
39
|
+
assert.ok(result.includes('content="1234567890"'))
|
|
40
|
+
assert.ok(!result.includes('@@'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should handle multiple occurrences of the same placeholder', async () => {
|
|
44
|
+
const template = '@@config._defaultLanguage @@config._defaultLanguage'
|
|
45
|
+
await fs.writeFile(path.join(tmpDir, 'index.html'), template)
|
|
46
|
+
|
|
47
|
+
await applyBuildReplacements(tmpDir, {
|
|
48
|
+
defaultLanguage: 'de',
|
|
49
|
+
defaultDirection: 'ltr',
|
|
50
|
+
buildType: 'production',
|
|
51
|
+
timestamp: 0
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const result = await fs.readFile(path.join(tmpDir, 'index.html'), 'utf8')
|
|
55
|
+
assert.equal(result, 'de de')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
import { collectMigrationScripts } from '../lib/utils/collectMigrationScripts.js'
|
|
8
|
+
|
|
9
|
+
describe('collectMigrationScripts()', () => {
|
|
10
|
+
let tmpDir
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fw-mig-'))
|
|
14
|
+
const srcDir = path.join(tmpDir, 'src')
|
|
15
|
+
await fs.mkdir(path.join(srcDir, 'core', 'migrations'), { recursive: true })
|
|
16
|
+
await fs.mkdir(path.join(srcDir, 'components', 'adapt-contrib-text', 'migrations'), { recursive: true })
|
|
17
|
+
|
|
18
|
+
await fs.writeFile(path.join(srcDir, 'core', 'migrations', '6.24.2.js'), '// core migration')
|
|
19
|
+
await fs.writeFile(path.join(srcDir, 'components', 'adapt-contrib-text', 'migrations', '5.0.1.js'), '// text migration')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.rm(tmpDir, { recursive: true })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should find core and plugin migration scripts', async () => {
|
|
27
|
+
const scripts = await collectMigrationScripts(tmpDir)
|
|
28
|
+
assert.equal(scripts.length, 2)
|
|
29
|
+
assert.ok(scripts.some(s => s.includes('core/migrations/6.24.2.js')))
|
|
30
|
+
assert.ok(scripts.some(s => s.includes('adapt-contrib-text/migrations/5.0.1.js')))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should return absolute paths', async () => {
|
|
34
|
+
const scripts = await collectMigrationScripts(tmpDir)
|
|
35
|
+
scripts.forEach(s => assert.ok(path.isAbsolute(s)))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should return empty array when no migration scripts exist', async () => {
|
|
39
|
+
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fw-nomig-'))
|
|
40
|
+
await fs.mkdir(path.join(emptyDir, 'src'), { recursive: true })
|
|
41
|
+
const scripts = await collectMigrationScripts(emptyDir)
|
|
42
|
+
assert.deepEqual(scripts, [])
|
|
43
|
+
await fs.rm(emptyDir, { recursive: true })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { before, describe, it, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
describe('computePluginHash()', () => {
|
|
5
|
+
let computePluginHash
|
|
6
|
+
|
|
7
|
+
before(async () => {
|
|
8
|
+
mock.module('adapt-cli/lib/integration/Project.js', {
|
|
9
|
+
defaultExport: class MockProject {
|
|
10
|
+
constructor ({ cwd }) { this.cwd = cwd }
|
|
11
|
+
async getInstalledDependencies () {
|
|
12
|
+
return {
|
|
13
|
+
'adapt-contrib-text': '1.0.0',
|
|
14
|
+
'adapt-contrib-narrative': '2.0.0',
|
|
15
|
+
'adapt-contrib-core': '3.0.0'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
;({ computePluginHash } = await import('../lib/utils/computePluginHash.js'))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should return a 16-character hex string', async () => {
|
|
24
|
+
const hash = await computePluginHash('/fake/framework')
|
|
25
|
+
assert.match(hash, /^[0-9a-f]{16}$/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should return the same hash for the same plugin set', async () => {
|
|
29
|
+
const hash1 = await computePluginHash('/fake/framework')
|
|
30
|
+
const hash2 = await computePluginHash('/fake/framework')
|
|
31
|
+
assert.equal(hash1, hash2)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should produce a deterministic hash regardless of insertion order', async () => {
|
|
35
|
+
// The mock always returns the same deps — verify stability
|
|
36
|
+
const hash1 = await computePluginHash('/path/a')
|
|
37
|
+
const hash2 = await computePluginHash('/path/b')
|
|
38
|
+
assert.equal(hash1, hash2)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { generateLanguageManifest } from '../lib/utils/generateLanguageManifest.js'
|
|
5
|
+
|
|
6
|
+
describe('generateLanguageManifest()', () => {
|
|
7
|
+
it('should return all filenames except the manifest and assets.json', () => {
|
|
8
|
+
const input = ['course.json', 'contentObjects.json', 'articles.json', 'language_data_manifest.js', 'assets.json']
|
|
9
|
+
const result = generateLanguageManifest(input)
|
|
10
|
+
assert.deepEqual(result, ['course.json', 'contentObjects.json', 'articles.json'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should return an empty array when only excluded files are present', () => {
|
|
14
|
+
const result = generateLanguageManifest(['language_data_manifest.js', 'assets.json'])
|
|
15
|
+
assert.deepEqual(result, [])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should return all filenames when no exclusions apply', () => {
|
|
19
|
+
const input = ['course.json', 'blocks.json']
|
|
20
|
+
const result = generateLanguageManifest(input)
|
|
21
|
+
assert.deepEqual(result, ['course.json', 'blocks.json'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should handle an empty input array', () => {
|
|
25
|
+
assert.deepEqual(generateLanguageManifest([]), [])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const mockContentModule = {
|
|
5
|
+
find: mock.fn(async () => [
|
|
6
|
+
{ _id: 'course1', _type: 'course', title: 'Course 1' }
|
|
7
|
+
]),
|
|
8
|
+
findOne: mock.fn(async ({ _id, _type, _courseId }) => {
|
|
9
|
+
if (_type === 'config') return { _id: 'cfg1', _type: 'config', _courseId }
|
|
10
|
+
return { _id, _type: 'course', title: 'Course 1' }
|
|
11
|
+
}),
|
|
12
|
+
update: mock.fn(async () => {})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mock.module('adapt-authoring-core', {
|
|
16
|
+
namedExports: {
|
|
17
|
+
App: {
|
|
18
|
+
instance: {
|
|
19
|
+
waitForModule: mock.fn(async () => mockContentModule)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const mockCollectMigrationScripts = mock.fn(async () => ['/path/to/script.js'])
|
|
26
|
+
mock.module('../lib/utils/collectMigrationScripts.js', {
|
|
27
|
+
namedExports: {
|
|
28
|
+
collectMigrationScripts: mockCollectMigrationScripts
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const mockRunContentMigration = mock.fn(async ({ content }) => {
|
|
33
|
+
return content.map(item => ({
|
|
34
|
+
...item,
|
|
35
|
+
title: item.title ? item.title + ' (migrated)' : item.title
|
|
36
|
+
}))
|
|
37
|
+
})
|
|
38
|
+
mock.module('../lib/utils/runContentMigration.js', {
|
|
39
|
+
namedExports: {
|
|
40
|
+
runContentMigration: mockRunContentMigration
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
mock.module('../lib/utils/log.js', {
|
|
45
|
+
namedExports: {
|
|
46
|
+
log: () => {}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const { migrateExistingCourses } = await import('../lib/utils/migrateExistingCourses.js')
|
|
51
|
+
|
|
52
|
+
describe('migrateExistingCourses()', () => {
|
|
53
|
+
it('should collect migration scripts from frameworkDir', async () => {
|
|
54
|
+
mockCollectMigrationScripts.mock.resetCalls()
|
|
55
|
+
await migrateExistingCourses({
|
|
56
|
+
fromPlugins: [{ name: 'core', version: '1.0.0' }],
|
|
57
|
+
toPlugins: [{ name: 'core', version: '2.0.0' }],
|
|
58
|
+
frameworkDir: '/fw'
|
|
59
|
+
})
|
|
60
|
+
assert.equal(mockCollectMigrationScripts.mock.calls.length, 1)
|
|
61
|
+
assert.equal(mockCollectMigrationScripts.mock.calls[0].arguments[0], '/fw')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should query all courses when no courseIds provided', async () => {
|
|
65
|
+
mockContentModule.find.mock.resetCalls()
|
|
66
|
+
await migrateExistingCourses({
|
|
67
|
+
fromPlugins: [],
|
|
68
|
+
toPlugins: [],
|
|
69
|
+
frameworkDir: '/fw'
|
|
70
|
+
})
|
|
71
|
+
const findCalls = mockContentModule.find.mock.calls
|
|
72
|
+
assert.ok(findCalls.some(c =>
|
|
73
|
+
JSON.stringify(c.arguments[0]) === JSON.stringify({ _type: 'course' })
|
|
74
|
+
))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should return migration result counts', async () => {
|
|
78
|
+
mockContentModule.find.mock.resetCalls()
|
|
79
|
+
mockContentModule.update.mock.resetCalls()
|
|
80
|
+
mockRunContentMigration.mock.resetCalls()
|
|
81
|
+
mockRunContentMigration.mock.mockImplementation(async ({ content }) => {
|
|
82
|
+
return content.map(item => ({
|
|
83
|
+
...item,
|
|
84
|
+
title: item.title ? item.title + ' (migrated)' : item.title
|
|
85
|
+
}))
|
|
86
|
+
})
|
|
87
|
+
const result = await migrateExistingCourses({
|
|
88
|
+
fromPlugins: [{ name: 'core', version: '1.0.0' }],
|
|
89
|
+
toPlugins: [{ name: 'core', version: '2.0.0' }],
|
|
90
|
+
frameworkDir: '/fw'
|
|
91
|
+
})
|
|
92
|
+
assert.equal(result.migrated, 1)
|
|
93
|
+
assert.equal(result.failed, 0)
|
|
94
|
+
assert.deepEqual(result.errors, [])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should only update changed items in DB', async () => {
|
|
98
|
+
mockContentModule.update.mock.resetCalls()
|
|
99
|
+
mockRunContentMigration.mock.mockImplementation(async ({ content }) => {
|
|
100
|
+
return content.map(item => ({
|
|
101
|
+
...item,
|
|
102
|
+
title: item.title ? item.title + ' (migrated)' : item.title
|
|
103
|
+
}))
|
|
104
|
+
})
|
|
105
|
+
await migrateExistingCourses({
|
|
106
|
+
fromPlugins: [],
|
|
107
|
+
toPlugins: [],
|
|
108
|
+
frameworkDir: '/fw'
|
|
109
|
+
})
|
|
110
|
+
assert.ok(mockContentModule.update.mock.calls.length > 0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should skip DB writes when content is unchanged', async () => {
|
|
114
|
+
mockContentModule.update.mock.resetCalls()
|
|
115
|
+
mockRunContentMigration.mock.mockImplementation(async ({ content }) => content)
|
|
116
|
+
await migrateExistingCourses({
|
|
117
|
+
fromPlugins: [],
|
|
118
|
+
toPlugins: [],
|
|
119
|
+
frameworkDir: '/fw'
|
|
120
|
+
})
|
|
121
|
+
assert.equal(mockContentModule.update.mock.calls.length, 0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should return early with zero counts when no scripts found', async () => {
|
|
125
|
+
mockCollectMigrationScripts.mock.mockImplementation(async () => [])
|
|
126
|
+
const result = await migrateExistingCourses({
|
|
127
|
+
fromPlugins: [],
|
|
128
|
+
toPlugins: [],
|
|
129
|
+
frameworkDir: '/fw'
|
|
130
|
+
})
|
|
131
|
+
assert.equal(result.migrated, 0)
|
|
132
|
+
assert.equal(result.failed, 0)
|
|
133
|
+
// restore
|
|
134
|
+
mockCollectMigrationScripts.mock.mockImplementation(async () => ['/path/to/script.js'])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should isolate per-course errors and continue', async () => {
|
|
138
|
+
mockContentModule.find.mock.mockImplementation(async (query) => {
|
|
139
|
+
if (query._type === 'course') {
|
|
140
|
+
return [
|
|
141
|
+
{ _id: 'course1', _type: 'course', title: 'OK' },
|
|
142
|
+
{ _id: 'course2', _type: 'course', title: 'Fails' }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
return []
|
|
146
|
+
})
|
|
147
|
+
let callCount = 0
|
|
148
|
+
mockRunContentMigration.mock.mockImplementation(async ({ content }) => {
|
|
149
|
+
callCount++
|
|
150
|
+
if (callCount === 2) throw new Error('migration error')
|
|
151
|
+
return content.map(item => ({ ...item, title: item.title + ' (migrated)' }))
|
|
152
|
+
})
|
|
153
|
+
const result = await migrateExistingCourses({
|
|
154
|
+
fromPlugins: [],
|
|
155
|
+
toPlugins: [],
|
|
156
|
+
frameworkDir: '/fw'
|
|
157
|
+
})
|
|
158
|
+
assert.equal(result.migrated, 1)
|
|
159
|
+
assert.equal(result.failed, 1)
|
|
160
|
+
assert.equal(result.errors.length, 1)
|
|
161
|
+
assert.equal(result.errors[0].courseId, 'course2')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
import { readFrameworkPluginVersions } from '../lib/utils/readFrameworkPluginVersions.js'
|
|
8
|
+
|
|
9
|
+
describe('readFrameworkPluginVersions()', () => {
|
|
10
|
+
let tmpDir
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fw-test-'))
|
|
14
|
+
const srcDir = path.join(tmpDir, 'src')
|
|
15
|
+
await fs.mkdir(path.join(srcDir, 'core'), { recursive: true })
|
|
16
|
+
await fs.mkdir(path.join(srcDir, 'components', 'adapt-contrib-text'), { recursive: true })
|
|
17
|
+
await fs.mkdir(path.join(srcDir, 'extensions', 'adapt-contrib-trickle'), { recursive: true })
|
|
18
|
+
|
|
19
|
+
await fs.writeFile(path.join(srcDir, 'core', 'bower.json'), JSON.stringify({ name: 'adapt-contrib-core', version: '6.24.1' }))
|
|
20
|
+
await fs.writeFile(path.join(srcDir, 'components', 'adapt-contrib-text', 'bower.json'), JSON.stringify({ name: 'adapt-contrib-text', version: '5.0.0' }))
|
|
21
|
+
await fs.writeFile(path.join(srcDir, 'extensions', 'adapt-contrib-trickle', 'bower.json'), JSON.stringify({ name: 'adapt-contrib-trickle', version: '4.2.1' }))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fs.rm(tmpDir, { recursive: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should return plugin names and versions from bower.json files', async () => {
|
|
29
|
+
const plugins = await readFrameworkPluginVersions(tmpDir)
|
|
30
|
+
assert.equal(plugins.length, 3)
|
|
31
|
+
const names = plugins.map(p => p.name).sort()
|
|
32
|
+
assert.deepEqual(names, ['adapt-contrib-core', 'adapt-contrib-text', 'adapt-contrib-trickle'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should return name and version for each plugin', async () => {
|
|
36
|
+
const plugins = await readFrameworkPluginVersions(tmpDir)
|
|
37
|
+
const core = plugins.find(p => p.name === 'adapt-contrib-core')
|
|
38
|
+
assert.equal(core.version, '6.24.1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should return empty array when src dir has no bower files', async () => {
|
|
42
|
+
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fw-empty-'))
|
|
43
|
+
await fs.mkdir(path.join(emptyDir, 'src'), { recursive: true })
|
|
44
|
+
const plugins = await readFrameworkPluginVersions(emptyDir)
|
|
45
|
+
assert.deepEqual(plugins, [])
|
|
46
|
+
await fs.rm(emptyDir, { recursive: true })
|
|
47
|
+
})
|
|
48
|
+
})
|