adapt-authoring-adaptframework 2.6.0 → 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/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": "2.6.0",
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": "^2.0.0",
14
+ "adapt-authoring-content": "^3.0.0",
15
15
  "adapt-authoring-contentplugin": "^1.0.3",
16
- "adapt-authoring-core": "^2.0.0",
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-octopus": "^0.1.2",
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 keep value when not in assetMap', () => {
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.img, 'unknown/path.png')
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 and course assets', async () => {
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
+ })