adapt-authoring-contentplugin 1.2.3 → 1.4.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.
@@ -3,6 +3,14 @@ import apidefs from './apidefs.js'
3
3
  import fs from 'fs/promises'
4
4
  import { glob } from 'glob'
5
5
  import path from 'path'
6
+ import { readJson } from 'adapt-authoring-core'
7
+ import {
8
+ backupPluginVersion,
9
+ getMostRecentBackup,
10
+ cleanupOldPluginBackups,
11
+ restorePluginFromBackup,
12
+ processPluginFiles
13
+ } from './utils.js'
6
14
  import semver from 'semver'
7
15
  /**
8
16
  * Abstract module which handles framework plugins
@@ -107,7 +115,7 @@ class ContentPluginModule extends AbstractApiModule {
107
115
  }
108
116
 
109
117
  async readJson (filepath) {
110
- return JSON.parse(await fs.readFile(filepath))
118
+ return readJson(filepath)
111
119
  }
112
120
 
113
121
  /**
@@ -360,30 +368,7 @@ class ContentPluginModule extends AbstractApiModule {
360
368
  * @returns {Promise<String|null>} Path to the backup directory, or null if no backup was created
361
369
  */
362
370
  async backupPluginVersion (pluginPath, pluginName) {
363
- try {
364
- await fs.access(pluginPath)
365
- } catch (e) { // No plugin, no backup needed
366
- return null
367
- }
368
- let existingVersion
369
- try {
370
- const pkgPath = path.join(pluginPath, 'package.json')
371
- const pkg = await this.readJson(pkgPath)
372
- existingVersion = pkg.version
373
- } catch (e) {
374
- try {
375
- const bowerPath = path.join(pluginPath, 'bower.json')
376
- const bower = await this.readJson(bowerPath)
377
- existingVersion = bower.version
378
- } catch (e2) {
379
- this.log('warn', `Could not read version for backup of ${pluginName}`)
380
- existingVersion = `unknown-${Date.now()}`
381
- }
382
- }
383
- const backupDir = `${pluginPath}-v${existingVersion}`
384
- await fs.rename(pluginPath, backupDir)
385
- this.log('info', `Backed up ${pluginName}@${existingVersion}`)
386
- return backupDir
371
+ return backupPluginVersion(pluginPath, pluginName, this.log.bind(this))
387
372
  }
388
373
 
389
374
  /**
@@ -393,54 +378,11 @@ class ContentPluginModule extends AbstractApiModule {
393
378
  * @returns {Promise<String|null>} Path to the most recent backup, or null if none found
394
379
  */
395
380
  async getMostRecentBackup (pluginDir, pluginName) {
396
- const pattern = `${pluginName}-v*`
397
- const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
398
-
399
- if (backups.length === 0) {
400
- return null
401
- }
402
-
403
- // Sort by version (newest first)
404
- // Extract version from backup path (format: pluginName-vX.Y.Z)
405
- backups.sort((a, b) => {
406
- const versionA = path.basename(a).replace(`${pluginName}-v`, '')
407
- const versionB = path.basename(b).replace(`${pluginName}-v`, '')
408
-
409
- // Try to compare as semver versions
410
- if (semver.valid(versionA) && semver.valid(versionB)) {
411
- return semver.rcompare(versionA, versionB) // descending order
412
- }
413
-
414
- // Fall back to alphabetical comparison for non-semver versions (e.g., unknown-timestamp)
415
- return b.localeCompare(a)
416
- })
417
-
418
- return backups[0]
381
+ return getMostRecentBackup(pluginDir, pluginName)
419
382
  }
420
383
 
421
- /**
422
- * Cleans up old plugin version backups, keeping only the most recent one
423
- * @param {String} pluginDir Base directory containing plugins
424
- * @param {String} pluginName Name of the plugin
425
- * @returns {Promise<void>}
426
- */
427
384
  async cleanupOldPluginBackups (pluginDir, pluginName) {
428
- const pattern = `${pluginName}-v*`
429
- const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
430
-
431
- if (backups.length <= 1) {
432
- return
433
- }
434
-
435
- // Get the most recent backup using the helper
436
- const mostRecent = await this.getMostRecentBackup(pluginDir, pluginName)
437
-
438
- // Remove all backups except the most recent
439
- const backupsToRemove = backups.filter(backup => backup !== mostRecent)
440
- for (const backup of backupsToRemove) {
441
- await fs.rm(backup, { recursive: true })
442
- this.log('info', `Removed old backup: ${backup}`)
443
- }
385
+ return cleanupOldPluginBackups(pluginDir, pluginName, this.log.bind(this))
444
386
  }
445
387
 
446
388
  /**
@@ -450,77 +392,23 @@ class ContentPluginModule extends AbstractApiModule {
450
392
  */
451
393
  async restorePluginFromBackup (pluginName) {
452
394
  const pluginDir = this.getConfig('pluginDir')
453
- const pluginPath = path.join(pluginDir, pluginName)
454
- const mostRecentBackup = await this.getMostRecentBackup(pluginDir, pluginName)
455
-
456
- if (!mostRecentBackup) {
395
+ const result = await restorePluginFromBackup(pluginDir, pluginName, this.log.bind(this))
396
+ if (!result) {
457
397
  throw this.app.errors.NOT_FOUND
458
398
  .setData({ type: 'backup', id: pluginName })
459
399
  }
460
- // Remove current version if it exists
461
- try {
462
- await fs.access(pluginPath)
463
- await fs.rm(pluginPath, { recursive: true })
464
- } catch (e) {
465
- // Current version doesn't exist, that's fine
466
- }
467
- // Restore the backup
468
- await fs.rename(mostRecentBackup, pluginPath)
469
- this.log('info', `Restored ${pluginName} from backup`)
470
- let pkg
471
- try {
472
- pkg = await this.readJson(path.join(pluginPath, 'package.json'))
473
- } catch (e) {
474
- try {
475
- pkg = await this.readJson(path.join(pluginPath, 'bower.json'))
476
- } catch (e2) {
477
- throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
478
- .setData({ pluginName, message: 'Could not read package.json or bower.json from backup' })
479
- }
480
- }
481
- return pkg
400
+ return result
482
401
  }
483
402
 
484
- /**
485
- * Ensures local plugin source files are stored in the correct location and structured in an expected way
486
- * @param {Object} pluginData Plugin metadata
487
- * @param {String} sourcePath The path to the plugin source files
488
- * @returns Resolves with package data
489
- */
490
403
  async processPluginFiles (pluginData) {
491
- let sourcePath = pluginData.sourcePath
492
- if (sourcePath === path.basename(sourcePath)) { // no local files
493
- return { name: pluginData.name, version: sourcePath, isLocalInstall: false }
494
- }
495
- const contents = await fs.readdir(sourcePath)
496
- if (contents.length === 1) { // deal with a nested root folder
497
- sourcePath = path.join(pluginData.sourcePath, contents[0])
498
- }
499
- let pkg
500
- try { // load package data, with fall-back to bower
501
- try {
502
- pkg = await this.readJson(path.join(sourcePath, 'package.json'))
503
- } catch (e) {
504
- pkg = await this.readJson(path.join(sourcePath, 'bower.json'))
505
- }
506
- pkg.sourcePath = path.join(this.getConfig('pluginDir'), pkg.name)
507
- pkg.isLocalInstall = true
404
+ try {
405
+ return await processPluginFiles(pluginData, this.getConfig('pluginDir'), this.log.bind(this))
508
406
  } catch (e) {
509
- throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
407
+ if (e.message?.startsWith('Invalid plugin zip')) {
408
+ throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
409
+ }
410
+ throw e
510
411
  }
511
-
512
- const pluginDir = this.getConfig('pluginDir')
513
-
514
- // Back up the existing version if it exists
515
- await this.backupPluginVersion(pkg.sourcePath, pkg.name)
516
-
517
- // Clean up old backups (keep only 1 previous version)
518
- await this.cleanupOldPluginBackups(pluginDir, pkg.name)
519
-
520
- // move the files into the persistent location
521
- await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
522
- await fs.rm(sourcePath, { recursive: true })
523
- return pkg
524
412
  }
525
413
 
526
414
  /**
@@ -0,0 +1,43 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { readJson } from 'adapt-authoring-core'
4
+
5
+ /**
6
+ * Creates a versioned backup of an existing plugin directory.
7
+ *
8
+ * Reads the plugin version from package.json (or bower.json as fallback),
9
+ * removes any stale backup at the target path, then renames the plugin
10
+ * directory to `<pluginPath>-v<version>`.
11
+ *
12
+ * @param {string} pluginPath - Absolute path to the plugin directory
13
+ * @param {string} pluginName - Human-readable plugin name (for logging)
14
+ * @param {Function} [log] - Optional logging callback `(level, msg) => void`
15
+ * @returns {Promise<string|null>} Path to the backup directory, or null if no backup was needed
16
+ */
17
+ export async function backupPluginVersion (pluginPath, pluginName, log) {
18
+ try {
19
+ await fs.access(pluginPath)
20
+ } catch (e) { // No plugin, no backup needed
21
+ return null
22
+ }
23
+ let existingVersion
24
+ try {
25
+ const pkg = await readJson(path.join(pluginPath, 'package.json'))
26
+ existingVersion = pkg.version
27
+ } catch (e) {
28
+ try {
29
+ const bower = await readJson(path.join(pluginPath, 'bower.json'))
30
+ existingVersion = bower.version
31
+ } catch (e2) {
32
+ if (log) log('warn', `Could not read version for backup of ${pluginName}`)
33
+ existingVersion = `unknown-${Date.now()}`
34
+ }
35
+ }
36
+ const backupDir = `${pluginPath}-v${existingVersion}`
37
+ try {
38
+ await fs.rm(backupDir, { recursive: true, force: true })
39
+ } catch (e) {} // ignore if doesn't exist
40
+ await fs.rename(pluginPath, backupDir)
41
+ if (log) log('info', `Backed up ${pluginName}@${existingVersion}`)
42
+ return backupDir
43
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'fs/promises'
2
+ import { glob } from 'glob'
3
+ import { getMostRecentBackup } from './getMostRecentBackup.js'
4
+
5
+ /**
6
+ * Cleans up old plugin version backups, keeping only the most recent one.
7
+ *
8
+ * @param {string} pluginDir - Base directory containing plugins
9
+ * @param {string} pluginName - Name of the plugin
10
+ * @param {Function} [log] - Optional logging callback `(level, msg) => void`
11
+ * @returns {Promise<void>}
12
+ */
13
+ export async function cleanupOldPluginBackups (pluginDir, pluginName, log) {
14
+ const pattern = `${pluginName}-v*`
15
+ const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
16
+
17
+ if (backups.length <= 1) {
18
+ return
19
+ }
20
+
21
+ const mostRecent = await getMostRecentBackup(pluginDir, pluginName)
22
+
23
+ const backupsToRemove = backups.filter(backup => backup !== mostRecent)
24
+ for (const backup of backupsToRemove) {
25
+ await fs.rm(backup, { recursive: true })
26
+ if (log) log('info', `Removed old backup: ${backup}`)
27
+ }
28
+ }
@@ -0,0 +1,37 @@
1
+ import { glob } from 'glob'
2
+ import path from 'path'
3
+ import semver from 'semver'
4
+
5
+ /**
6
+ * Gets the most recent backup for a plugin based on version sorting.
7
+ *
8
+ * Scans for directories matching `<pluginName>-v*` in the given directory,
9
+ * sorts by semver (falling back to alphabetical for non-semver), and
10
+ * returns the most recent.
11
+ *
12
+ * @param {string} pluginDir - Base directory containing plugins
13
+ * @param {string} pluginName - Name of the plugin
14
+ * @returns {Promise<string|null>} Absolute path to the most recent backup, or null if none found
15
+ */
16
+ export async function getMostRecentBackup (pluginDir, pluginName) {
17
+ const pattern = `${pluginName}-v*`
18
+ const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
19
+
20
+ if (backups.length === 0) {
21
+ return null
22
+ }
23
+
24
+ // Sort by version (newest first)
25
+ backups.sort((a, b) => {
26
+ const versionA = path.basename(a).replace(`${pluginName}-v`, '')
27
+ const versionB = path.basename(b).replace(`${pluginName}-v`, '')
28
+
29
+ if (semver.valid(versionA) && semver.valid(versionB)) {
30
+ return semver.rcompare(versionA, versionB)
31
+ }
32
+
33
+ return b.localeCompare(a)
34
+ })
35
+
36
+ return backups[0]
37
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { readJson } from 'adapt-authoring-core'
4
+ import { backupPluginVersion } from './backupPluginVersion.js'
5
+ import { cleanupOldPluginBackups } from './cleanupOldPluginBackups.js'
6
+
7
+ /**
8
+ * Processes local plugin source files for installation.
9
+ *
10
+ * If `sourcePath` is just a filename (no directory component), returns a
11
+ * non-local install descriptor. Otherwise reads the package metadata from
12
+ * the source, backs up any existing version, cleans old backups, and copies
13
+ * the source files into their persistent location under `pluginDir`.
14
+ *
15
+ * @param {Object} pluginData - Plugin metadata (must include `name` and `sourcePath`)
16
+ * @param {string} pluginDir - Persistent plugin storage directory
17
+ * @param {Function} [log] - Optional logging callback `(level, msg) => void`
18
+ * @returns {Promise<Object>} Package metadata with `sourcePath` and `isLocalInstall` fields
19
+ * @throws {Error} If the source contains no valid package.json or bower.json
20
+ */
21
+ export async function processPluginFiles (pluginData, pluginDir, log) {
22
+ let sourcePath = pluginData.sourcePath
23
+ if (sourcePath === path.basename(sourcePath)) { // no local files
24
+ return { name: pluginData.name, version: sourcePath, isLocalInstall: false }
25
+ }
26
+ const contents = await fs.readdir(sourcePath)
27
+ if (contents.length === 1) { // deal with a nested root folder
28
+ sourcePath = path.join(pluginData.sourcePath, contents[0])
29
+ }
30
+ let pkg
31
+ try {
32
+ try {
33
+ pkg = await readJson(path.join(sourcePath, 'package.json'))
34
+ } catch (e) {
35
+ pkg = await readJson(path.join(sourcePath, 'bower.json'))
36
+ }
37
+ pkg.sourcePath = path.join(pluginDir, pkg.name)
38
+ pkg.isLocalInstall = true
39
+ } catch (e) {
40
+ throw new Error(`Invalid plugin zip: no package.json or bower.json found in ${sourcePath}`)
41
+ }
42
+
43
+ // Back up the existing version if it exists
44
+ await backupPluginVersion(pkg.sourcePath, pkg.name, log)
45
+
46
+ // Clean up old backups (keep only 1 previous version)
47
+ await cleanupOldPluginBackups(pluginDir, pkg.name, log)
48
+
49
+ // move the files into the persistent location
50
+ await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
51
+ await fs.rm(sourcePath, { recursive: true })
52
+ return pkg
53
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { getMostRecentBackup } from './getMostRecentBackup.js'
4
+ import { readJson } from 'adapt-authoring-core'
5
+
6
+ /**
7
+ * Restores a plugin from its most recent versioned backup.
8
+ *
9
+ * Finds the newest backup directory, removes the current plugin directory
10
+ * (if present), renames the backup into place, and reads the package metadata.
11
+ *
12
+ * @param {string} pluginDir - Base directory containing plugins
13
+ * @param {string} pluginName - Name of the plugin to restore
14
+ * @param {Function} [log] - Optional logging callback `(level, msg) => void`
15
+ * @returns {Promise<Object|null>} Package metadata from the restored backup,
16
+ * or null if no backup was found
17
+ * @throws {Error} If the restored backup contains no package.json or bower.json
18
+ */
19
+ export async function restorePluginFromBackup (pluginDir, pluginName, log) {
20
+ const pluginPath = path.join(pluginDir, pluginName)
21
+ const mostRecentBackup = await getMostRecentBackup(pluginDir, pluginName)
22
+
23
+ if (!mostRecentBackup) {
24
+ return null
25
+ }
26
+ // Remove current version if it exists
27
+ try {
28
+ await fs.access(pluginPath)
29
+ await fs.rm(pluginPath, { recursive: true })
30
+ } catch (e) {
31
+ // Current version doesn't exist, that's fine
32
+ }
33
+ // Restore the backup
34
+ await fs.rename(mostRecentBackup, pluginPath)
35
+ if (log) log('info', `Restored ${pluginName} from backup`)
36
+ try {
37
+ return await readJson(path.join(pluginPath, 'package.json'))
38
+ } catch (e) {
39
+ try {
40
+ return await readJson(path.join(pluginPath, 'bower.json'))
41
+ } catch (e2) {
42
+ throw new Error(`Could not read package.json or bower.json from backup of ${pluginName}`)
43
+ }
44
+ }
45
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,5 @@
1
+ export { backupPluginVersion } from './utils/backupPluginVersion.js'
2
+ export { getMostRecentBackup } from './utils/getMostRecentBackup.js'
3
+ export { cleanupOldPluginBackups } from './utils/cleanupOldPluginBackups.js'
4
+ export { restorePluginFromBackup } from './utils/restorePluginFromBackup.js'
5
+ export { processPluginFiles } from './utils/processPluginFiles.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-contentplugin",
3
- "version": "1.2.3",
3
+ "version": "1.4.0",
4
4
  "description": "Module for managing framework plugins",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-contentplugin",
6
6
  "repository": "github:adapt-security/adapt-authoring-contentplugin",
@@ -10,17 +10,17 @@
10
10
  "main": "index.js",
11
11
  "type": "module",
12
12
  "dependencies": {
13
+ "adapt-authoring-core": "^2.0.0",
13
14
  "adapt-cli": "^3.3.3",
14
15
  "glob": "^13.0.0",
15
16
  "semver": "^7.6.0"
16
17
  },
17
18
  "peerDependencies": {
18
- "adapt-authoring-adaptframework": "^1.9.3",
19
- "adapt-authoring-content": "^1.2.3",
20
- "adapt-authoring-core": "^1.7.0",
19
+ "adapt-authoring-adaptframework": "^2.0.0",
20
+ "adapt-authoring-content": "^2.0.0",
21
21
  "adapt-authoring-jsonschema": "^1.2.0",
22
22
  "adapt-authoring-middleware": "^1.0.2",
23
- "adapt-authoring-mongodb": "^1.1.3"
23
+ "adapt-authoring-mongodb": "^3.0.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@semantic-release/git": "^10.0.1",
@@ -0,0 +1,513 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'fs/promises'
4
+ import path from 'path'
5
+ import os from 'os'
6
+
7
+ import { readJson } from 'adapt-authoring-core'
8
+ import {
9
+ backupPluginVersion,
10
+ getMostRecentBackup,
11
+ cleanupOldPluginBackups,
12
+ restorePluginFromBackup,
13
+ processPluginFiles
14
+ } from '../lib/utils.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // readJson
18
+ // ---------------------------------------------------------------------------
19
+ describe('readJson()', () => {
20
+ let tmpDir
21
+
22
+ beforeEach(async () => {
23
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
24
+ })
25
+
26
+ afterEach(async () => {
27
+ await fs.rm(tmpDir, { recursive: true, force: true })
28
+ })
29
+
30
+ it('should parse a valid JSON file', async () => {
31
+ const filePath = path.join(tmpDir, 'data.json')
32
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test', version: '1.0.0' }))
33
+ const result = await readJson(filePath)
34
+ assert.deepEqual(result, { name: 'test', version: '1.0.0' })
35
+ })
36
+
37
+ it('should throw on invalid JSON', async () => {
38
+ const filePath = path.join(tmpDir, 'bad.json')
39
+ await fs.writeFile(filePath, '{ not valid }')
40
+ await assert.rejects(() => readJson(filePath), SyntaxError)
41
+ })
42
+
43
+ it('should throw ENOENT when file does not exist', async () => {
44
+ await assert.rejects(
45
+ () => readJson(path.join(tmpDir, 'nope.json')),
46
+ (err) => err.code === 'ENOENT'
47
+ )
48
+ })
49
+ })
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // backupPluginVersion
53
+ // ---------------------------------------------------------------------------
54
+ describe('backupPluginVersion()', () => {
55
+ let tmpDir
56
+ let log
57
+
58
+ beforeEach(async () => {
59
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
60
+ log = mock.fn()
61
+ })
62
+
63
+ afterEach(async () => {
64
+ await fs.rm(tmpDir, { recursive: true, force: true })
65
+ })
66
+
67
+ /**
68
+ * Helper: create a fake plugin directory with a package.json.
69
+ */
70
+ async function createPlugin (name, version, { useBower = false } = {}) {
71
+ const pluginPath = path.join(tmpDir, name)
72
+ await fs.mkdir(pluginPath, { recursive: true })
73
+ const filename = useBower ? 'bower.json' : 'package.json'
74
+ await fs.writeFile(
75
+ path.join(pluginPath, filename),
76
+ JSON.stringify({ name, version })
77
+ )
78
+ return pluginPath
79
+ }
80
+
81
+ it('should return null when the plugin directory does not exist', async () => {
82
+ const result = await backupPluginVersion(
83
+ path.join(tmpDir, 'nonexistent'),
84
+ 'nonexistent',
85
+ log
86
+ )
87
+ assert.equal(result, null)
88
+ assert.equal(log.mock.callCount(), 0)
89
+ })
90
+
91
+ it('should rename the plugin directory to a versioned backup', async () => {
92
+ const pluginPath = await createPlugin('adapt-hotgrid', '4.3.5')
93
+
94
+ const result = await backupPluginVersion(pluginPath, 'adapt-hotgrid', log)
95
+
96
+ const expectedBackup = `${pluginPath}-v4.3.5`
97
+ assert.equal(result, expectedBackup)
98
+
99
+ // Original should be gone, backup should exist
100
+ await assert.rejects(() => fs.access(pluginPath), { code: 'ENOENT' })
101
+ await fs.access(expectedBackup) // should not throw
102
+ })
103
+
104
+ it('should read version from bower.json when package.json is absent', async () => {
105
+ const pluginPath = await createPlugin('adapt-vanilla', '2.1.0', { useBower: true })
106
+
107
+ const result = await backupPluginVersion(pluginPath, 'adapt-vanilla', log)
108
+
109
+ assert.equal(result, `${pluginPath}-v2.1.0`)
110
+ })
111
+
112
+ it('should use unknown-<timestamp> when neither package.json nor bower.json exist', async () => {
113
+ const pluginPath = path.join(tmpDir, 'adapt-empty')
114
+ await fs.mkdir(pluginPath)
115
+
116
+ const result = await backupPluginVersion(pluginPath, 'adapt-empty', log)
117
+
118
+ assert.ok(result.startsWith(`${pluginPath}-vunknown-`))
119
+ // Should have logged a warning
120
+ const warns = log.mock.calls.filter(c => c.arguments[0] === 'warn')
121
+ assert.equal(warns.length, 1)
122
+ })
123
+
124
+ it('should succeed when a backup directory already exists (bug #2 regression)', async () => {
125
+ const pluginPath = await createPlugin('adapt-hotgrid', '4.3.5')
126
+ const backupDir = `${pluginPath}-v4.3.5`
127
+
128
+ // Pre-create a stale backup with content (simulates previous test run)
129
+ await fs.mkdir(backupDir, { recursive: true })
130
+ await fs.writeFile(path.join(backupDir, 'stale-file.txt'), 'old data')
131
+
132
+ // This would fail with ENOTEMPTY before the fix
133
+ const result = await backupPluginVersion(pluginPath, 'adapt-hotgrid', log)
134
+
135
+ assert.equal(result, backupDir)
136
+ // Original should be gone
137
+ await assert.rejects(() => fs.access(pluginPath), { code: 'ENOENT' })
138
+ // Backup should exist and contain the new package.json, not the stale file
139
+ const pkg = JSON.parse(await fs.readFile(path.join(backupDir, 'package.json'), 'utf8'))
140
+ assert.equal(pkg.version, '4.3.5')
141
+ await assert.rejects(
142
+ () => fs.access(path.join(backupDir, 'stale-file.txt')),
143
+ { code: 'ENOENT' }
144
+ )
145
+ })
146
+
147
+ it('should log an info message on success', async () => {
148
+ const pluginPath = await createPlugin('adapt-text', '1.0.0')
149
+
150
+ await backupPluginVersion(pluginPath, 'adapt-text', log)
151
+
152
+ const infoCalls = log.mock.calls.filter(c => c.arguments[0] === 'info')
153
+ assert.equal(infoCalls.length, 1)
154
+ assert.ok(infoCalls[0].arguments[1].includes('adapt-text'))
155
+ })
156
+
157
+ it('should work without a log callback', async () => {
158
+ const pluginPath = await createPlugin('adapt-nolog', '1.0.0')
159
+
160
+ // Should not throw even though log is undefined
161
+ const result = await backupPluginVersion(pluginPath, 'adapt-nolog')
162
+
163
+ assert.equal(result, `${pluginPath}-v1.0.0`)
164
+ })
165
+ })
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // getMostRecentBackup
169
+ // ---------------------------------------------------------------------------
170
+ describe('getMostRecentBackup()', () => {
171
+ let tmpDir
172
+
173
+ beforeEach(async () => {
174
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
175
+ })
176
+
177
+ afterEach(async () => {
178
+ await fs.rm(tmpDir, { recursive: true, force: true })
179
+ })
180
+
181
+ it('should return null when no backups exist', async () => {
182
+ const result = await getMostRecentBackup(tmpDir, 'adapt-hotgrid')
183
+ assert.equal(result, null)
184
+ })
185
+
186
+ it('should return the only backup when there is one', async () => {
187
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
188
+
189
+ const result = await getMostRecentBackup(tmpDir, 'adapt-hotgrid')
190
+
191
+ assert.equal(result, path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
192
+ })
193
+
194
+ it('should return the highest semver version', async () => {
195
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
196
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v2.3.1'))
197
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.9.9'))
198
+
199
+ const result = await getMostRecentBackup(tmpDir, 'adapt-hotgrid')
200
+
201
+ assert.equal(result, path.join(tmpDir, 'adapt-hotgrid-v2.3.1'))
202
+ })
203
+
204
+ it('should handle non-semver versions with alphabetical fallback', async () => {
205
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-vunknown-100'))
206
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-vunknown-200'))
207
+
208
+ const result = await getMostRecentBackup(tmpDir, 'adapt-hotgrid')
209
+
210
+ assert.equal(result, path.join(tmpDir, 'adapt-hotgrid-vunknown-200'))
211
+ })
212
+
213
+ it('should not match backups for other plugins', async () => {
214
+ await fs.mkdir(path.join(tmpDir, 'adapt-text-v1.0.0'))
215
+
216
+ const result = await getMostRecentBackup(tmpDir, 'adapt-hotgrid')
217
+
218
+ assert.equal(result, null)
219
+ })
220
+ })
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // cleanupOldPluginBackups
224
+ // ---------------------------------------------------------------------------
225
+ describe('cleanupOldPluginBackups()', () => {
226
+ let tmpDir
227
+ let log
228
+
229
+ beforeEach(async () => {
230
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
231
+ log = mock.fn()
232
+ })
233
+
234
+ afterEach(async () => {
235
+ await fs.rm(tmpDir, { recursive: true, force: true })
236
+ })
237
+
238
+ it('should do nothing when no backups exist', async () => {
239
+ await cleanupOldPluginBackups(tmpDir, 'adapt-hotgrid', log)
240
+ assert.equal(log.mock.callCount(), 0)
241
+ })
242
+
243
+ it('should do nothing when only one backup exists', async () => {
244
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
245
+
246
+ await cleanupOldPluginBackups(tmpDir, 'adapt-hotgrid', log)
247
+
248
+ // Backup should still exist
249
+ await fs.access(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
250
+ assert.equal(log.mock.callCount(), 0)
251
+ })
252
+
253
+ it('should remove all but the most recent backup', async () => {
254
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
255
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v2.0.0'))
256
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v3.0.0'))
257
+
258
+ await cleanupOldPluginBackups(tmpDir, 'adapt-hotgrid', log)
259
+
260
+ // v3.0.0 should survive, the others should be gone
261
+ await fs.access(path.join(tmpDir, 'adapt-hotgrid-v3.0.0'))
262
+ await assert.rejects(() => fs.access(path.join(tmpDir, 'adapt-hotgrid-v1.0.0')), { code: 'ENOENT' })
263
+ await assert.rejects(() => fs.access(path.join(tmpDir, 'adapt-hotgrid-v2.0.0')), { code: 'ENOENT' })
264
+ })
265
+
266
+ it('should log info for each removed backup', async () => {
267
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
268
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v2.0.0'))
269
+
270
+ await cleanupOldPluginBackups(tmpDir, 'adapt-hotgrid', log)
271
+
272
+ const infoCalls = log.mock.calls.filter(c => c.arguments[0] === 'info')
273
+ assert.equal(infoCalls.length, 1)
274
+ })
275
+
276
+ it('should not affect backups from other plugins', async () => {
277
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v1.0.0'))
278
+ await fs.mkdir(path.join(tmpDir, 'adapt-hotgrid-v2.0.0'))
279
+ await fs.mkdir(path.join(tmpDir, 'adapt-text-v1.0.0'))
280
+
281
+ await cleanupOldPluginBackups(tmpDir, 'adapt-hotgrid', log)
282
+
283
+ // adapt-text backup should be untouched
284
+ await fs.access(path.join(tmpDir, 'adapt-text-v1.0.0'))
285
+ })
286
+ })
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // restorePluginFromBackup
290
+ // ---------------------------------------------------------------------------
291
+ describe('restorePluginFromBackup()', () => {
292
+ let tmpDir
293
+ let log
294
+
295
+ beforeEach(async () => {
296
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
297
+ log = mock.fn()
298
+ })
299
+
300
+ afterEach(async () => {
301
+ await fs.rm(tmpDir, { recursive: true, force: true })
302
+ })
303
+
304
+ it('should return null when no backup exists', async () => {
305
+ const result = await restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log)
306
+ assert.equal(result, null)
307
+ })
308
+
309
+ it('should restore the most recent backup into the plugin directory', async () => {
310
+ const backupDir = path.join(tmpDir, 'adapt-hotgrid-v2.0.0')
311
+ await fs.mkdir(backupDir)
312
+ await fs.writeFile(path.join(backupDir, 'package.json'), JSON.stringify({ name: 'adapt-hotgrid', version: '2.0.0' }))
313
+
314
+ const result = await restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log)
315
+
316
+ assert.equal(result.name, 'adapt-hotgrid')
317
+ assert.equal(result.version, '2.0.0')
318
+ // Plugin dir should now exist, backup dir should be gone
319
+ await fs.access(path.join(tmpDir, 'adapt-hotgrid'))
320
+ await assert.rejects(() => fs.access(backupDir), { code: 'ENOENT' })
321
+ })
322
+
323
+ it('should remove the current plugin directory before restoring', async () => {
324
+ // Create current version
325
+ const pluginPath = path.join(tmpDir, 'adapt-hotgrid')
326
+ await fs.mkdir(pluginPath)
327
+ await fs.writeFile(path.join(pluginPath, 'package.json'), JSON.stringify({ name: 'adapt-hotgrid', version: '3.0.0' }))
328
+ // Create backup
329
+ const backupDir = path.join(tmpDir, 'adapt-hotgrid-v2.0.0')
330
+ await fs.mkdir(backupDir)
331
+ await fs.writeFile(path.join(backupDir, 'package.json'), JSON.stringify({ name: 'adapt-hotgrid', version: '2.0.0' }))
332
+
333
+ const result = await restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log)
334
+
335
+ assert.equal(result.version, '2.0.0')
336
+ })
337
+
338
+ it('should read version from bower.json if no package.json', async () => {
339
+ const backupDir = path.join(tmpDir, 'adapt-hotgrid-v1.0.0')
340
+ await fs.mkdir(backupDir)
341
+ await fs.writeFile(path.join(backupDir, 'bower.json'), JSON.stringify({ name: 'adapt-hotgrid', version: '1.0.0' }))
342
+
343
+ const result = await restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log)
344
+
345
+ assert.equal(result.version, '1.0.0')
346
+ })
347
+
348
+ it('should throw when backup has neither package.json nor bower.json', async () => {
349
+ const backupDir = path.join(tmpDir, 'adapt-hotgrid-v1.0.0')
350
+ await fs.mkdir(backupDir)
351
+ await fs.writeFile(path.join(backupDir, 'readme.txt'), 'no metadata')
352
+
353
+ await assert.rejects(
354
+ () => restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log),
355
+ (err) => err.message.includes('Could not read package.json or bower.json')
356
+ )
357
+ })
358
+
359
+ it('should log an info message on success', async () => {
360
+ const backupDir = path.join(tmpDir, 'adapt-hotgrid-v1.0.0')
361
+ await fs.mkdir(backupDir)
362
+ await fs.writeFile(path.join(backupDir, 'package.json'), JSON.stringify({ name: 'adapt-hotgrid', version: '1.0.0' }))
363
+
364
+ await restorePluginFromBackup(tmpDir, 'adapt-hotgrid', log)
365
+
366
+ const infoCalls = log.mock.calls.filter(c => c.arguments[0] === 'info')
367
+ assert.equal(infoCalls.length, 1)
368
+ assert.ok(infoCalls[0].arguments[1].includes('adapt-hotgrid'))
369
+ })
370
+ })
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // processPluginFiles
374
+ // ---------------------------------------------------------------------------
375
+ describe('processPluginFiles()', () => {
376
+ let tmpDir
377
+ let pluginDir
378
+ let log
379
+
380
+ beforeEach(async () => {
381
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpu-test-'))
382
+ pluginDir = path.join(tmpDir, 'plugins')
383
+ await fs.mkdir(pluginDir)
384
+ log = mock.fn()
385
+ })
386
+
387
+ afterEach(async () => {
388
+ await fs.rm(tmpDir, { recursive: true, force: true })
389
+ })
390
+
391
+ it('should return a non-local install when sourcePath has no directory component', async () => {
392
+ const result = await processPluginFiles(
393
+ { name: 'adapt-hotgrid', sourcePath: '4.3.5' },
394
+ pluginDir,
395
+ log
396
+ )
397
+ assert.deepEqual(result, { name: 'adapt-hotgrid', version: '4.3.5', isLocalInstall: false })
398
+ })
399
+
400
+ it('should read package.json and copy source to pluginDir', async () => {
401
+ // Create source directory with plugin files
402
+ const sourcePath = path.join(tmpDir, 'source', 'adapt-hotgrid')
403
+ await fs.mkdir(sourcePath, { recursive: true })
404
+ await fs.writeFile(
405
+ path.join(sourcePath, 'package.json'),
406
+ JSON.stringify({ name: 'adapt-hotgrid', version: '4.3.5' })
407
+ )
408
+ await fs.writeFile(path.join(sourcePath, 'index.js'), 'module.exports = {}')
409
+
410
+ const result = await processPluginFiles(
411
+ { name: 'adapt-hotgrid', sourcePath },
412
+ pluginDir,
413
+ log
414
+ )
415
+
416
+ assert.equal(result.name, 'adapt-hotgrid')
417
+ assert.equal(result.version, '4.3.5')
418
+ assert.equal(result.isLocalInstall, true)
419
+ assert.equal(result.sourcePath, path.join(pluginDir, 'adapt-hotgrid'))
420
+
421
+ // Plugin should now be in pluginDir
422
+ const copiedPkg = await readJson(path.join(pluginDir, 'adapt-hotgrid', 'package.json'))
423
+ assert.equal(copiedPkg.version, '4.3.5')
424
+
425
+ // Source should be removed
426
+ await assert.rejects(() => fs.access(sourcePath), { code: 'ENOENT' })
427
+ })
428
+
429
+ it('should fall back to bower.json when package.json is absent', async () => {
430
+ const sourcePath = path.join(tmpDir, 'source', 'adapt-vanilla')
431
+ await fs.mkdir(sourcePath, { recursive: true })
432
+ await fs.writeFile(
433
+ path.join(sourcePath, 'bower.json'),
434
+ JSON.stringify({ name: 'adapt-vanilla', version: '2.0.0' })
435
+ )
436
+ await fs.writeFile(path.join(sourcePath, 'index.js'), '')
437
+
438
+ const result = await processPluginFiles(
439
+ { name: 'adapt-vanilla', sourcePath },
440
+ pluginDir,
441
+ log
442
+ )
443
+
444
+ assert.equal(result.name, 'adapt-vanilla')
445
+ assert.equal(result.isLocalInstall, true)
446
+ })
447
+
448
+ it('should handle a nested root folder in the source', async () => {
449
+ // Source has a single subfolder containing the actual plugin
450
+ const sourcePath = path.join(tmpDir, 'source')
451
+ await fs.mkdir(path.join(sourcePath, 'adapt-hotgrid'), { recursive: true })
452
+ await fs.writeFile(
453
+ path.join(sourcePath, 'adapt-hotgrid', 'package.json'),
454
+ JSON.stringify({ name: 'adapt-hotgrid', version: '1.0.0' })
455
+ )
456
+
457
+ const result = await processPluginFiles(
458
+ { name: 'adapt-hotgrid', sourcePath },
459
+ pluginDir,
460
+ log
461
+ )
462
+
463
+ assert.equal(result.name, 'adapt-hotgrid')
464
+ assert.equal(result.isLocalInstall, true)
465
+ })
466
+
467
+ it('should throw when source has no package.json or bower.json', async () => {
468
+ const sourcePath = path.join(tmpDir, 'source', 'bad-plugin')
469
+ await fs.mkdir(sourcePath, { recursive: true })
470
+ await fs.writeFile(path.join(sourcePath, 'readme.txt'), 'no metadata')
471
+
472
+ await assert.rejects(
473
+ () => processPluginFiles(
474
+ { name: 'bad-plugin', sourcePath },
475
+ pluginDir,
476
+ log
477
+ ),
478
+ (err) => err.message.startsWith('Invalid plugin zip')
479
+ )
480
+ })
481
+
482
+ it('should back up existing plugin before copying new version', async () => {
483
+ // Pre-existing plugin in pluginDir
484
+ const existingPath = path.join(pluginDir, 'adapt-hotgrid')
485
+ await fs.mkdir(existingPath)
486
+ await fs.writeFile(
487
+ path.join(existingPath, 'package.json'),
488
+ JSON.stringify({ name: 'adapt-hotgrid', version: '3.0.0' })
489
+ )
490
+
491
+ // New source
492
+ const sourcePath = path.join(tmpDir, 'source', 'adapt-hotgrid')
493
+ await fs.mkdir(sourcePath, { recursive: true })
494
+ await fs.writeFile(
495
+ path.join(sourcePath, 'package.json'),
496
+ JSON.stringify({ name: 'adapt-hotgrid', version: '4.0.0' })
497
+ )
498
+ await fs.writeFile(path.join(sourcePath, 'index.js'), '')
499
+
500
+ const result = await processPluginFiles(
501
+ { name: 'adapt-hotgrid', sourcePath },
502
+ pluginDir,
503
+ log
504
+ )
505
+
506
+ assert.equal(result.version, '4.0.0')
507
+ // Backup should exist
508
+ await fs.access(path.join(pluginDir, 'adapt-hotgrid-v3.0.0'))
509
+ // New version should be in place
510
+ const pkg = await readJson(path.join(pluginDir, 'adapt-hotgrid', 'package.json'))
511
+ assert.equal(pkg.version, '4.0.0')
512
+ })
513
+ })