adapt-authoring-core 1.9.2 → 2.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.
@@ -1,6 +1,6 @@
1
1
  # Writing tests
2
2
 
3
- Instructions for writing tests in this module.
3
+ Instructions for writing tests in this project. Both unit tests (per-module) and integration tests (full application) use the same stack and assertion style.
4
4
 
5
5
  ## Stack
6
6
 
@@ -9,57 +9,7 @@ Instructions for writing tests in this module.
9
9
 
10
10
  No external test dependencies are needed. Do not introduce Mocha, Jest, or other test frameworks.
11
11
 
12
- > **Note:** Some older modules in the project use Mocha + Should.js. New tests in this module use the built-in Node.js test library instead.
13
-
14
- ## File placement and naming
15
-
16
- - Tests live in a `tests/` directory at the module root
17
- - One test file per source module, named `<moduleName>.spec.js`
18
- - Test data/fixtures go in `tests/data/`
19
-
20
- ```
21
- my-module/
22
- ├── lib/
23
- │ ├── myModule.js
24
- │ └── myUtils.js
25
- └── tests/
26
- ├── data/
27
- │ └── fixtures.json
28
- ├── myModule.spec.js
29
- └── myUtils.spec.js
30
- ```
31
-
32
- ## File structure
33
-
34
- Every test file follows this structure:
35
-
36
- ```js
37
- import { describe, it, before } from 'node:test'
38
- import assert from 'node:assert/strict'
39
- import MyModule from '../lib/myModule.js'
40
-
41
- describe('My Module', () => {
42
- let instance
43
-
44
- before(() => {
45
- instance = new MyModule()
46
- })
47
-
48
- describe('#methodName()', () => {
49
- it('should describe expected behaviour', () => {
50
- assert.equal(instance.methodName(), 'expected')
51
- })
52
- })
53
- })
54
- ```
55
-
56
- Key rules:
57
-
58
- - Import `describe`, `it`, `before`, `after` etc. from `node:test`
59
- - Import `assert` from `node:assert/strict` (strict mode uses `deepStrictEqual` by default)
60
- - Use ES module `import` syntax — this project is `"type": "module"`
61
- - Group tests by method using nested `describe` blocks, prefixed with `#` for instance methods
62
- - Store shared state in `let` variables scoped to the `describe` block
12
+ > **Note:** Some older modules in the project use Mocha + Should.js. New tests use the built-in Node.js test library instead.
63
13
 
64
14
  ## Assertions
65
15
 
@@ -96,6 +46,17 @@ assert.notEqual(a, b)
96
46
  assert.notDeepEqual(obj1, obj2)
97
47
  ```
98
48
 
49
+ ## Async tests
50
+
51
+ `node:test` supports `async/await` directly:
52
+
53
+ ```js
54
+ it('should connect successfully', async () => {
55
+ const result = await instance.connect()
56
+ assert.equal(typeof result, 'object')
57
+ })
58
+ ```
59
+
99
60
  ## Dynamic test generation
100
61
 
101
62
  When testing a function against multiple inputs, generate tests in a loop:
@@ -117,18 +78,64 @@ invalidInputs.forEach((input) => {
117
78
  })
118
79
  ```
119
80
 
120
- ## Async tests
81
+ ## General rules
121
82
 
122
- `node:test` supports `async/await` directly:
83
+ - Import `describe`, `it`, `before`, `after` etc. from `node:test`
84
+ - Import `assert` from `node:assert/strict` (strict mode uses `deepStrictEqual` by default)
85
+ - Use ES module `import` syntax — this project is `"type": "module"`
86
+ - Store shared state in `let` variables scoped to the `describe` block
87
+ - Don't add external test dependencies — `node:test` and `node:assert` are sufficient
88
+ - Don't mock more than necessary — prefer testing real behaviour
89
+
90
+ ---
91
+
92
+ ## Unit tests
93
+
94
+ Unit tests live inside individual modules and test classes and utilities in isolation.
95
+
96
+ ### File placement and naming
97
+
98
+ - Tests live in a `tests/` directory at the module root
99
+ - One test file per source module, named `<moduleName>.spec.js`
100
+ - Test data/fixtures go in `tests/data/`
101
+
102
+ ```
103
+ my-module/
104
+ ├── lib/
105
+ │ ├── myModule.js
106
+ │ └── myUtils.js
107
+ └── tests/
108
+ ├── data/
109
+ │ └── fixtures.json
110
+ ├── myModule.spec.js
111
+ └── myUtils.spec.js
112
+ ```
113
+
114
+ ### File structure
123
115
 
124
116
  ```js
125
- it('should connect successfully', async () => {
126
- const result = await instance.connect()
127
- assert.equal(typeof result, 'object')
117
+ import { describe, it, before } from 'node:test'
118
+ import assert from 'node:assert/strict'
119
+ import MyModule from '../lib/myModule.js'
120
+
121
+ describe('My Module', () => {
122
+ let instance
123
+
124
+ before(() => {
125
+ instance = new MyModule()
126
+ })
127
+
128
+ describe('#methodName()', () => {
129
+ it('should describe expected behaviour', () => {
130
+ assert.equal(instance.methodName(), 'expected')
131
+ })
132
+ })
128
133
  })
129
134
  ```
130
135
 
131
- ## Test data
136
+ - Group tests by method using nested `describe` blocks, prefixed with `#` for instance methods
137
+
138
+ ### Test data
132
139
 
133
140
  Place fixture files in `tests/data/`. Use `import` or `fs` to load them:
134
141
 
@@ -141,33 +148,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
141
148
  const fixtures = JSON.parse(readFileSync(join(__dirname, 'data', 'fixtures.json')))
142
149
  ```
143
150
 
144
- ## What to test
151
+ ### What to test
145
152
 
146
153
  - All public methods on exported classes and utilities
147
154
  - Both success and error paths
148
155
  - Edge cases (empty input, missing arguments, invalid types)
149
156
  - That errors are thrown or returned where expected (use `assert.throws` / `assert.rejects`)
150
157
 
151
- ## What NOT to do
158
+ ### What NOT to do
152
159
 
153
160
  - Don't test private/internal methods (prefixed with `_`)
154
- - Don't add external test dependencies — `node:test` and `node:assert` are sufficient
155
161
  - Don't write tests that depend on execution order between `describe` blocks
156
- - Don't mock more than necessary — prefer testing real behaviour
157
162
 
158
- ## Add script to package.json
163
+ ### Add script to package.json
159
164
 
160
- The tests should be accessible via an npm script in package.json:
161
- ```
165
+ ```json
162
166
  "scripts": {
163
167
  "test": "node --test tests/"
164
168
  }
165
169
  ```
166
170
 
167
- ## Add GitHub workflow
171
+ ### Add GitHub workflow
168
172
 
169
- The following workflow should be added to /.github:
170
- ```
173
+ The following workflow should be added to `/.github`:
174
+
175
+ ```yaml
171
176
  name: Tests
172
177
  on: push
173
178
  jobs:
@@ -183,7 +188,7 @@ jobs:
183
188
  - run: npm test
184
189
  ```
185
190
 
186
- ## Running tests
191
+ ### Running unit tests
187
192
 
188
193
  ```bash
189
194
  # run the test suite
@@ -194,3 +199,212 @@ npx standard
194
199
  ```
195
200
 
196
201
  Both must pass before submitting a pull request.
202
+
203
+ ---
204
+
205
+ ## Integration tests
206
+
207
+ Integration tests live in the `integration-tests/` package and test the full application with a real database. They boot the app once and exercise cross-module workflows such as authentication, content CRUD, course import, and course build/export.
208
+
209
+ See the [integration-tests README](../../integration-tests/README.md) for setup and run instructions.
210
+
211
+ ### File placement and naming
212
+
213
+ All integration test files live in `integration-tests/tests/`, named `<area>.spec.js`:
214
+
215
+ ```
216
+ integration-tests/
217
+ ├── bin/
218
+ │ └── run.js # test runner entry point
219
+ ├── lib/
220
+ │ ├── app.js # app bootstrap helpers
221
+ │ ├── db.js # database utilities
222
+ │ └── fixtures.js # fixture management
223
+ ├── tests/
224
+ │ ├── auth.spec.js
225
+ │ ├── content.spec.js
226
+ │ ├── mongodb.spec.js
227
+ │ ├── adaptframework-import.spec.js
228
+ │ └── ...
229
+ └── fixtures/
230
+ ├── manifest.json # maps fixture keys to files
231
+ └── course-export.zip
232
+ ```
233
+
234
+ ### App bootstrap helpers
235
+
236
+ Integration tests share a single app instance. Use the helpers from `lib/app.js`:
237
+
238
+ ```js
239
+ import { getApp, getModule, cleanDb } from '../lib/app.js'
240
+
241
+ // getApp() — boots the app (cached, only boots once per run)
242
+ // getModule() — waits for a named module to be ready (e.g. 'content', 'auth-local')
243
+ // cleanDb() — deletes documents from test collections
244
+ ```
245
+
246
+ ### File structure
247
+
248
+ Every integration test follows this pattern:
249
+
250
+ ```js
251
+ import { describe, it, before, after } from 'node:test'
252
+ import assert from 'node:assert/strict'
253
+ import { getApp, getModule, cleanDb } from '../lib/app.js'
254
+
255
+ let content
256
+
257
+ describe('Content CRUD operations', () => {
258
+ before(async () => {
259
+ await getApp()
260
+ content = await getModule('content')
261
+ })
262
+
263
+ after(async () => {
264
+ await cleanDb(['content', 'users', 'authtokens'])
265
+ })
266
+
267
+ describe('Course creation', () => {
268
+ it('should insert a course', async () => {
269
+ const course = await content.insert(
270
+ { _type: 'course', title: 'Test Course', createdBy },
271
+ { validate: false, schemaName: 'course' }
272
+ )
273
+ assert.ok(course._id, 'course should have an _id')
274
+ assert.equal(course._type, 'course')
275
+ })
276
+ })
277
+ })
278
+ ```
279
+
280
+ Key differences from unit tests:
281
+
282
+ - Always call `await getApp()` in `before()` to ensure the app is booted
283
+ - Use `getModule(name)` to get module instances — never import modules directly
284
+ - Always clean up in `after()` using `cleanDb(collections)` to avoid cross-test pollution
285
+
286
+ ### Database cleanup
287
+
288
+ `cleanDb()` accepts an array of collection names to clear. Pass only the collections your tests touch:
289
+
290
+ ```js
291
+ after(async () => {
292
+ await cleanDb(['content', 'users', 'authtokens'])
293
+ })
294
+ ```
295
+
296
+ The default collections (used when called with no arguments) are: `content`, `assets`, `courseassets`, `tags`, `adaptbuilds`, `contentplugins`.
297
+
298
+ > **Important:** Always include `contentplugins` when cleaning content-related data. Stale plugin records cause `MISSING_SCHEMA` errors on subsequent runs.
299
+
300
+ ### Available modules
301
+
302
+ Modules are accessed by name via `getModule()`. Common modules used in tests:
303
+
304
+ | Name | Description |
305
+ |------|-------------|
306
+ | `content` | Content CRUD (`insert`, `find`, `update`, `delete`) |
307
+ | `auth` | Core authentication (token management, `disavowUser`) |
308
+ | `auth-local` | Local auth (`register`, `registerSuper`, `setUserEnabled`) |
309
+ | `users` | User management (`find`, `update`, `delete`) |
310
+ | `roles` | Role management (`find`, `getScopesForRole`) |
311
+ | `mongodb` | Raw database access (`find`, `insert`, `getCollection`) |
312
+ | `adaptframework` | Course import/build/export (`importCourse`, `buildCourse`) |
313
+
314
+ ### Fixtures
315
+
316
+ Integration tests use a fixture system for test data such as course export zips.
317
+
318
+ Fixtures are declared in `fixtures/manifest.json`:
319
+
320
+ ```json
321
+ {
322
+ "course-export": "course-export.zip"
323
+ }
324
+ ```
325
+
326
+ Load a fixture in your test with `getFixture()`, which copies the file to a temp directory to preserve the original:
327
+
328
+ ```js
329
+ import { getFixture } from '../lib/fixtures.js'
330
+
331
+ it('should import a course', async () => {
332
+ const framework = await getModule('adaptframework')
333
+ const importer = await framework.importCourse({
334
+ importPath: await getFixture('course-export'),
335
+ userId: '000000000000000000000000',
336
+ tags: [],
337
+ importContent: true,
338
+ importPlugins: true,
339
+ migrateContent: true,
340
+ updatePlugins: false,
341
+ removeSource: false
342
+ })
343
+ assert.ok(importer.summary.courseId)
344
+ })
345
+ ```
346
+
347
+ Custom fixtures can be provided via the `CUSTOM_DIR` environment variable. Custom fixtures override built-in fixtures when keys collide.
348
+
349
+ ### Writing assertions
350
+
351
+ Be specific with assertions. Check exact counts, validate required fields, and verify relationships — don't just check that something is truthy or non-empty:
352
+
353
+ ```js
354
+ // Bad — only checks existence
355
+ const items = await content.find({ _courseId, _type: 'block' })
356
+ assert.ok(items.length > 0, 'should have blocks')
357
+
358
+ // Good — checks exact count and validates structure
359
+ const items = await content.find({ _courseId, _type: 'block' })
360
+ assert.equal(items.length, 23, 'should have 23 blocks')
361
+ for (const item of items) {
362
+ assert.ok(item.title, `block "${item._id}" should have a title`)
363
+ assert.ok(articleIds.has(item._parentId?.toString()),
364
+ `block "${item._id}" should have an article as parent`)
365
+ }
366
+ ```
367
+
368
+ When validating content hierarchy, verify that parent references point to items of the correct type:
369
+
370
+ ```js
371
+ const blockIds = new Set(
372
+ (await content.find({ _courseId, _type: 'block' })).map(b => b._id.toString())
373
+ )
374
+ for (const component of components) {
375
+ assert.ok(blockIds.has(component._parentId?.toString()),
376
+ `component "${component._id}" should have a block as parent`)
377
+ }
378
+ ```
379
+
380
+ ### Error assertions
381
+
382
+ Use a predicate function with `assert.rejects` when errors may have different structures across modules:
383
+
384
+ ```js
385
+ await assert.rejects(
386
+ () => authLocal.registerSuper({ email: 'second@example.com', password }),
387
+ (err) => {
388
+ assert.ok(
389
+ err.code === 'SUPER_USER_EXISTS' || err.id === 'SUPER_USER_EXISTS',
390
+ `expected SUPER_USER_EXISTS, got: ${err.code || err.id || err.message}`
391
+ )
392
+ return true
393
+ }
394
+ )
395
+ ```
396
+
397
+ ### Running integration tests
398
+
399
+ From the adapt-authoring app directory:
400
+
401
+ ```bash
402
+ # run all integration tests
403
+ npx at-integration-test
404
+
405
+ # run specific test files
406
+ npx at-integration-test auth content
407
+
408
+ # include custom tests
409
+ CUSTOM_DIR=/path/to/custom npx at-integration-test
410
+ ```
package/index.js CHANGED
@@ -2,4 +2,4 @@ export { default as AbstractModule } from './lib/AbstractModule.js'
2
2
  export { default as App } from './lib/App.js'
3
3
  export { default as DependencyLoader } from './lib/DependencyLoader.js'
4
4
  export { default as Hook } from './lib/Hook.js'
5
- export { default as Utils } from './lib/Utils.js'
5
+ export { metadataFileName, packageFileName, isObject, getArgs, spawn, readJson, writeJson, toBoolean, ensureDir, escapeRegExp } from './lib/Utils.js'
package/lib/App.js CHANGED
@@ -2,7 +2,7 @@ import AbstractModule from './AbstractModule.js'
2
2
  import DependencyLoader from './DependencyLoader.js'
3
3
  import fs from 'fs'
4
4
  import path from 'path'
5
- import Utils from './Utils.js'
5
+ import { metadataFileName, packageFileName, getArgs } from './Utils.js'
6
6
 
7
7
  let instance
8
8
  /**
@@ -27,8 +27,8 @@ class App extends AbstractModule {
27
27
  /** @override */
28
28
  constructor () {
29
29
  const rootDir = process.env.ROOT_DIR ?? process.cwd()
30
- const adaptJson = JSON.parse(fs.readFileSync(path.join(rootDir, Utils.metadataFileName)))
31
- const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, Utils.packageFileName)))
30
+ const adaptJson = JSON.parse(fs.readFileSync(path.join(rootDir, metadataFileName)))
31
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, packageFileName)))
32
32
  super(null, { ...packageJson, ...adaptJson, name: 'adapt-authoring-core', rootDir })
33
33
  this.git = this.getGitInfo()
34
34
  }
@@ -39,7 +39,7 @@ class App extends AbstractModule {
39
39
  * Reference to the passed arguments (parsed for easy reference)
40
40
  * @type {Object}
41
41
  */
42
- this.args = Utils.getArgs()
42
+ this.args = getArgs()
43
43
  /**
44
44
  * Instance of App instance (required by all AbstractModules)
45
45
  * @type {App}
@@ -4,7 +4,7 @@ import fs from 'fs-extra'
4
4
  import { glob } from 'glob'
5
5
  import path from 'path'
6
6
  import Hook from './Hook.js'
7
- import Utils from './Utils.js'
7
+ import { metadataFileName, packageFileName } from './Utils.js'
8
8
  /**
9
9
  * Handles the loading of Adapt authoring tool module dependencies.
10
10
  * @memberof core
@@ -88,9 +88,9 @@ class DependencyLoader {
88
88
  */
89
89
  async loadConfigs () {
90
90
  /** @ignore */ this._configsLoaded = false
91
- const files = await glob(`${this.app.rootDir}/node_modules/**/${Utils.metadataFileName}`)
91
+ const files = await glob(`${this.app.rootDir}/node_modules/**/${metadataFileName}`)
92
92
  const deps = files
93
- .map(d => d.replace(`${Utils.metadataFileName}`, ''))
93
+ .map(d => d.replace(`${metadataFileName}`, ''))
94
94
  .sort((a, b) => a.length < b.length ? -1 : 1)
95
95
 
96
96
  const configCache = {}
@@ -125,8 +125,8 @@ class DependencyLoader {
125
125
  */
126
126
  async loadModuleConfig (modDir) {
127
127
  return {
128
- ...await fs.readJson(path.join(modDir, Utils.packageFileName)),
129
- ...await fs.readJson(path.join(modDir, Utils.metadataFileName)),
128
+ ...await fs.readJson(path.join(modDir, packageFileName)),
129
+ ...await fs.readJson(path.join(modDir, metadataFileName)),
130
130
  rootDir: modDir
131
131
  }
132
132
  }
package/lib/Utils.js CHANGED
@@ -1,76 +1,9 @@
1
- import App from './App.js'
2
- import minimist from 'minimist'
3
- import { spawn } from 'child_process'
4
- /**
5
- * Miscellaneous utility functions for use throughout the application
6
- * @memberof core
7
- */
8
- class Utils {
9
- /**
10
- * The name of the file used for defining Adapt authoring tool metadata
11
- * @return {String}
12
- */
13
- static get metadataFileName () {
14
- return 'adapt-authoring.json'
15
- }
16
-
17
- /**
18
- * The name of the Node.js package file
19
- * @return {String}
20
- */
21
- static get packageFileName () {
22
- return 'package.json'
23
- }
24
-
25
- /**
26
- * Returns the passed arguments, parsed by minimist for easy access
27
- * @return {Object} The parsed arguments
28
- * @see {@link https://github.com/substack/minimist#readme}
29
- */
30
- static getArgs () {
31
- const args = minimist(process.argv)
32
- args.params = args._.slice(2)
33
- return args
34
- }
35
-
36
- /**
37
- * Determines if param is a Javascript object (note: returns false for arrays, functions and null)
38
- * @return {Boolean}
39
- */
40
- static isObject (o) {
41
- return typeof o === 'object' && o !== null && !Array.isArray(o)
42
- }
43
-
44
- /**
45
- * Reusable Promise-based spawn wrapper, which handles output/error handling
46
- * @param {Object} options
47
- * @param {String} options.cmd Command to run
48
- * @param {String} options.cwd Current working directory
49
- * @param {Array<String>} options.args
50
- * @returns Promise
51
- */
52
- static async spawn (options) {
53
- return new Promise((resolve, reject) => {
54
- if (!options.cwd) options.cwd = ''
55
- App.instance.log('verbose', 'SPAWN', options)
56
- const task = spawn(options.cmd, options.args ?? [], { cwd: options.cwd })
57
- let stdout = ''
58
- let stderr = ''
59
- let error
60
- task.stdout.on('data', data => {
61
- stdout += data
62
- })
63
- task.stderr.on('data', data => {
64
- stderr += data
65
- })
66
- task.on('error', e => {
67
- error = e
68
- })
69
- task.on('close', exitCode => {
70
- exitCode !== 0 ? reject(App.instance.errors.SPAWN.setData({ error: error ?? stderr ?? stdout })) : resolve(stdout)
71
- })
72
- })
73
- }
74
- }
75
-
76
- export default Utils
1
+ export { metadataFileName, packageFileName } from './utils/constants.js'
2
+ export { isObject } from './utils/isObject.js'
3
+ export { getArgs } from './utils/getArgs.js'
4
+ export { spawn } from './utils/spawn.js'
5
+ export { readJson } from './utils/readJson.js'
6
+ export { writeJson } from './utils/writeJson.js'
7
+ export { toBoolean } from './utils/toBoolean.js'
8
+ export { ensureDir } from './utils/ensureDir.js'
9
+ export { escapeRegExp } from './utils/escapeRegExp.js'
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The name of the file used for defining Adapt authoring tool metadata
3
+ * @type {string}
4
+ */
5
+ export const metadataFileName = 'adapt-authoring.json'
6
+
7
+ /**
8
+ * The name of the Node.js package file
9
+ * @type {string}
10
+ */
11
+ export const packageFileName = 'package.json'
@@ -0,0 +1,14 @@
1
+ import fs from 'fs/promises'
2
+
3
+ /**
4
+ * Ensures a directory exists, creating it recursively if needed.
5
+ * @param {string} dir - Absolute path to the directory
6
+ * @returns {Promise<void>}
7
+ */
8
+ export async function ensureDir (dir) {
9
+ try {
10
+ await fs.mkdir(dir, { recursive: true })
11
+ } catch (e) {
12
+ if (e.code !== 'EEXIST') throw e
13
+ }
14
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Escapes special regex characters in a string.
3
+ * @param {string} string
4
+ * @returns {string}
5
+ */
6
+ export function escapeRegExp (string) {
7
+ return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&')
8
+ }
@@ -0,0 +1,12 @@
1
+ import minimist from 'minimist'
2
+
3
+ /**
4
+ * Returns the passed arguments, parsed by minimist for easy access
5
+ * @return {Object} The parsed arguments
6
+ * @see {@link https://github.com/substack/minimist#readme}
7
+ */
8
+ export function getArgs () {
9
+ const args = minimist(process.argv)
10
+ args.params = args._.slice(2)
11
+ return args
12
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Determines if param is a Javascript object (note: returns false for arrays, functions and null)
3
+ * @param {*} o
4
+ * @return {Boolean}
5
+ */
6
+ export function isObject (o) {
7
+ return typeof o === 'object' && o !== null && !Array.isArray(o)
8
+ }
@@ -0,0 +1,10 @@
1
+ import fs from 'fs/promises'
2
+
3
+ /**
4
+ * Reads and parses a JSON file.
5
+ * @param {string} filepath - Absolute path to the JSON file
6
+ * @returns {Promise<Object>}
7
+ */
8
+ export async function readJson (filepath) {
9
+ return JSON.parse(await fs.readFile(filepath))
10
+ }
@@ -0,0 +1,33 @@
1
+ import App from '../App.js'
2
+ import { spawn as nodeSpawn } from 'child_process'
3
+
4
+ /**
5
+ * Reusable Promise-based spawn wrapper, which handles output/error handling
6
+ * @param {Object} options
7
+ * @param {String} options.cmd Command to run
8
+ * @param {String} options.cwd Current working directory
9
+ * @param {Array<String>} options.args
10
+ * @returns Promise
11
+ */
12
+ export async function spawn (options) {
13
+ return new Promise((resolve, reject) => {
14
+ if (!options.cwd) options.cwd = ''
15
+ App.instance.log('verbose', 'SPAWN', options)
16
+ const task = nodeSpawn(options.cmd, options.args ?? [], { cwd: options.cwd })
17
+ let stdout = ''
18
+ let stderr = ''
19
+ let error
20
+ task.stdout.on('data', data => {
21
+ stdout += data
22
+ })
23
+ task.stderr.on('data', data => {
24
+ stderr += data
25
+ })
26
+ task.on('error', e => {
27
+ error = e
28
+ })
29
+ task.on('close', exitCode => {
30
+ exitCode !== 0 ? reject(App.instance.errors.SPAWN.setData({ error: error ?? stderr ?? stdout })) : resolve(stdout)
31
+ })
32
+ })
33
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Converts a value to a boolean. Returns `true` only for `true` or `"true"`.
3
+ * Returns `undefined` if the value is `undefined`.
4
+ * @param {*} val
5
+ * @returns {boolean|undefined}
6
+ */
7
+ export function toBoolean (val) {
8
+ if (val !== undefined) return val === true || val === 'true'
9
+ }
@@ -0,0 +1,11 @@
1
+ import fs from 'fs/promises'
2
+
3
+ /**
4
+ * Writes data as formatted JSON to a file.
5
+ * @param {string} filepath - Absolute path to the JSON file
6
+ * @param {*} data - Data to serialise
7
+ * @returns {Promise<void>}
8
+ */
9
+ export function writeJson (filepath, data) {
10
+ return fs.writeFile(filepath, JSON.stringify(data, null, 2))
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-core",
3
- "version": "1.9.2",
3
+ "version": "2.0.0",
4
4
  "description": "A bundle of reusable 'core' functionality",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-core",
6
6
  "license": "GPL-3.0",
@@ -0,0 +1,16 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { metadataFileName, packageFileName } from '../lib/utils/constants.js'
5
+
6
+ describe('metadataFileName', () => {
7
+ it('should be adapt-authoring.json', () => {
8
+ assert.equal(metadataFileName, 'adapt-authoring.json')
9
+ })
10
+ })
11
+
12
+ describe('packageFileName', () => {
13
+ it('should be package.json', () => {
14
+ assert.equal(packageFileName, 'package.json')
15
+ })
16
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, it } 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 { ensureDir } from '../lib/utils/ensureDir.js'
8
+
9
+ describe('ensureDir()', () => {
10
+ it('should create a directory that does not exist', async () => {
11
+ const tmpDir = path.join(os.tmpdir(), `ensureDir-test-${Date.now()}`)
12
+ try {
13
+ await ensureDir(tmpDir)
14
+ const stat = await fs.stat(tmpDir)
15
+ assert.ok(stat.isDirectory())
16
+ } finally {
17
+ await fs.rm(tmpDir, { recursive: true, force: true })
18
+ }
19
+ })
20
+
21
+ it('should create nested directories recursively', async () => {
22
+ const tmpDir = path.join(os.tmpdir(), `ensureDir-nested-${Date.now()}`, 'a', 'b', 'c')
23
+ try {
24
+ await ensureDir(tmpDir)
25
+ const stat = await fs.stat(tmpDir)
26
+ assert.ok(stat.isDirectory())
27
+ } finally {
28
+ await fs.rm(path.join(os.tmpdir(), `ensureDir-nested-${Date.now()}`), { recursive: true, force: true }).catch(() => {})
29
+ }
30
+ })
31
+
32
+ it('should not throw if the directory already exists', async () => {
33
+ const tmpDir = path.join(os.tmpdir(), `ensureDir-exists-${Date.now()}`)
34
+ try {
35
+ await fs.mkdir(tmpDir, { recursive: true })
36
+ await assert.doesNotReject(() => ensureDir(tmpDir))
37
+ } finally {
38
+ await fs.rm(tmpDir, { recursive: true, force: true })
39
+ }
40
+ })
41
+ })
@@ -0,0 +1,65 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { escapeRegExp } from '../lib/utils/escapeRegExp.js'
5
+
6
+ describe('escapeRegExp()', () => {
7
+ it('should escape dots', () => {
8
+ assert.equal(escapeRegExp('file.js'), 'file\\.js')
9
+ })
10
+
11
+ it('should escape asterisks', () => {
12
+ assert.equal(escapeRegExp('a*b'), 'a\\*b')
13
+ })
14
+
15
+ it('should escape plus signs', () => {
16
+ assert.equal(escapeRegExp('a+b'), 'a\\+b')
17
+ })
18
+
19
+ it('should escape question marks', () => {
20
+ assert.equal(escapeRegExp('a?b'), 'a\\?b')
21
+ })
22
+
23
+ it('should escape parentheses and pipe', () => {
24
+ assert.equal(escapeRegExp('(a|b)'), '\\(a\\|b\\)')
25
+ })
26
+
27
+ it('should escape square brackets', () => {
28
+ assert.equal(escapeRegExp('[abc]'), '\\[abc\\]')
29
+ })
30
+
31
+ it('should escape curly braces', () => {
32
+ assert.equal(escapeRegExp('{1,2}'), '\\{1,2\\}')
33
+ })
34
+
35
+ it('should escape caret and dollar', () => {
36
+ assert.equal(escapeRegExp('^start$'), '\\^start\\$')
37
+ })
38
+
39
+ it('should escape backslashes', () => {
40
+ assert.equal(escapeRegExp('a\\b'), 'a\\\\b')
41
+ })
42
+
43
+ it('should escape hyphens', () => {
44
+ assert.equal(escapeRegExp('a-b'), 'a\\-b')
45
+ })
46
+
47
+ it('should return plain strings unchanged', () => {
48
+ assert.equal(escapeRegExp('hello'), 'hello')
49
+ })
50
+
51
+ it('should escape all special characters', () => {
52
+ const special = '.*+\\-?^${}()|[]'
53
+ const escaped = escapeRegExp(special)
54
+ const re = new RegExp(escaped)
55
+ assert.ok(re.test(special))
56
+ })
57
+
58
+ it('should produce a string usable in RegExp', () => {
59
+ const input = 'file.name+(v2)[1].js'
60
+ const escaped = escapeRegExp(input)
61
+ const regex = new RegExp(escaped)
62
+ assert.ok(regex.test(input))
63
+ assert.ok(!regex.test('filexname'))
64
+ })
65
+ })
@@ -0,0 +1,22 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { getArgs } from '../lib/utils/getArgs.js'
5
+
6
+ describe('getArgs()', () => {
7
+ it('should return an object with parsed arguments', () => {
8
+ const args = getArgs()
9
+ assert.equal(typeof args, 'object')
10
+ assert.ok(Array.isArray(args.params))
11
+ })
12
+
13
+ it('should include the underscore array from minimist', () => {
14
+ const args = getArgs()
15
+ assert.ok(Array.isArray(args._))
16
+ })
17
+
18
+ it('should derive params by slicing first two entries from _', () => {
19
+ const args = getArgs()
20
+ assert.deepEqual(args.params, args._.slice(2))
21
+ })
22
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { isObject } from '../lib/utils/isObject.js'
5
+
6
+ describe('isObject()', () => {
7
+ const validObjects = [
8
+ { value: {}, label: 'empty object' },
9
+ { value: { key: 'value' }, label: 'object with properties' },
10
+ { value: { nested: { key: 'value' } }, label: 'nested object' }
11
+ ]
12
+
13
+ validObjects.forEach(({ value, label }) => {
14
+ it(`should return true for ${label}`, () => {
15
+ assert.equal(isObject(value), true)
16
+ })
17
+ })
18
+
19
+ const objectLikeButValid = [
20
+ { value: new Date(), label: 'Date instance' },
21
+ { value: /regex/, label: 'RegExp instance' }
22
+ ]
23
+
24
+ objectLikeButValid.forEach(({ value, label }) => {
25
+ it(`should return true for ${label}`, () => {
26
+ assert.equal(isObject(value), true)
27
+ })
28
+ })
29
+
30
+ const invalidObjects = [
31
+ { value: null, label: 'null' },
32
+ { value: [], label: 'empty array' },
33
+ { value: [1, 2, 3], label: 'array with values' },
34
+ { value: 'string', label: 'string' },
35
+ { value: 123, label: 'number' },
36
+ { value: true, label: 'boolean' },
37
+ { value: undefined, label: 'undefined' },
38
+ { value: () => {}, label: 'function' },
39
+ { value: 0, label: 'zero' },
40
+ { value: '', label: 'empty string' },
41
+ { value: NaN, label: 'NaN' }
42
+ ]
43
+
44
+ invalidObjects.forEach(({ value, label }) => {
45
+ it(`should return false for ${label}`, () => {
46
+ assert.equal(isObject(value), false)
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it } 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 '../lib/utils/readJson.js'
8
+
9
+ describe('readJson()', () => {
10
+ it('should read and parse a valid JSON file', async () => {
11
+ const tmpFile = path.join(os.tmpdir(), `readJson-test-${Date.now()}.json`)
12
+ await fs.writeFile(tmpFile, JSON.stringify({ name: 'test', version: '1.0.0' }))
13
+ try {
14
+ const result = await readJson(tmpFile)
15
+ assert.equal(result.name, 'test')
16
+ assert.equal(result.version, '1.0.0')
17
+ } finally {
18
+ await fs.unlink(tmpFile)
19
+ }
20
+ })
21
+
22
+ it('should handle nested JSON structures', async () => {
23
+ const tmpFile = path.join(os.tmpdir(), `readJson-nested-${Date.now()}.json`)
24
+ const data = { a: { b: [1, 2, 3] } }
25
+ await fs.writeFile(tmpFile, JSON.stringify(data))
26
+ try {
27
+ const result = await readJson(tmpFile)
28
+ assert.deepEqual(result, data)
29
+ } finally {
30
+ await fs.unlink(tmpFile)
31
+ }
32
+ })
33
+
34
+ it('should throw on non-existent file', async () => {
35
+ await assert.rejects(
36
+ () => readJson('/tmp/does-not-exist-readjson.json'),
37
+ { code: 'ENOENT' }
38
+ )
39
+ })
40
+
41
+ it('should throw on invalid JSON', async () => {
42
+ const tmpFile = path.join(os.tmpdir(), `readJson-invalid-${Date.now()}.json`)
43
+ await fs.writeFile(tmpFile, '{ not valid json }')
44
+ try {
45
+ await assert.rejects(
46
+ () => readJson(tmpFile),
47
+ SyntaxError
48
+ )
49
+ } finally {
50
+ await fs.unlink(tmpFile)
51
+ }
52
+ })
53
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import App from '../lib/App.js'
4
+
5
+ mock.getter(App, 'instance', () => ({
6
+ log () {},
7
+ errors: {
8
+ SPAWN: {
9
+ setData (data) {
10
+ const e = new Error('SPAWN')
11
+ e.data = data
12
+ return e
13
+ }
14
+ }
15
+ }
16
+ }))
17
+
18
+ const { spawn } = await import('../lib/utils/spawn.js')
19
+
20
+ describe('spawn()', () => {
21
+ it('should resolve with stdout on success', async () => {
22
+ const result = await spawn({ cmd: 'echo', args: ['hello'] })
23
+ assert.equal(result.trim(), 'hello')
24
+ })
25
+
26
+ it('should resolve with empty string when command produces no output', async () => {
27
+ const result = await spawn({ cmd: 'true' })
28
+ assert.equal(result, '')
29
+ })
30
+
31
+ it('should reject with SPAWN error on non-zero exit code', async () => {
32
+ await assert.rejects(
33
+ () => spawn({ cmd: 'false' }),
34
+ (err) => {
35
+ assert.equal(err.message, 'SPAWN')
36
+ return true
37
+ }
38
+ )
39
+ })
40
+
41
+ it('should reject with stderr data on failure', async () => {
42
+ await assert.rejects(
43
+ () => spawn({ cmd: 'node', args: ['-e', 'process.stderr.write("oops"); process.exit(1)'] }),
44
+ (err) => {
45
+ assert.equal(err.message, 'SPAWN')
46
+ assert.equal(err.data.error, 'oops')
47
+ return true
48
+ }
49
+ )
50
+ })
51
+
52
+ it('should default cwd to empty string when not provided', async () => {
53
+ const result = await spawn({ cmd: 'echo', args: ['test'] })
54
+ assert.equal(result.trim(), 'test')
55
+ })
56
+
57
+ it('should use provided cwd', async () => {
58
+ const result = await spawn({ cmd: 'pwd', cwd: '/tmp' })
59
+ assert.match(result.trim(), /tmp/)
60
+ })
61
+
62
+ it('should pass args to the command', async () => {
63
+ const result = await spawn({ cmd: 'echo', args: ['-n', 'no newline'] })
64
+ assert.equal(result, 'no newline')
65
+ })
66
+
67
+ it('should reject with error event data for invalid commands', async () => {
68
+ await assert.rejects(
69
+ () => spawn({ cmd: 'nonexistent-command-that-does-not-exist' }),
70
+ (err) => {
71
+ assert.equal(err.message, 'SPAWN')
72
+ return true
73
+ }
74
+ )
75
+ })
76
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { toBoolean } from '../lib/utils/toBoolean.js'
5
+
6
+ describe('toBoolean()', () => {
7
+ it('should return true for true', () => {
8
+ assert.equal(toBoolean(true), true)
9
+ })
10
+
11
+ it('should return true for "true"', () => {
12
+ assert.equal(toBoolean('true'), true)
13
+ })
14
+
15
+ it('should return false for false', () => {
16
+ assert.equal(toBoolean(false), false)
17
+ })
18
+
19
+ it('should return false for "false"', () => {
20
+ assert.equal(toBoolean('false'), false)
21
+ })
22
+
23
+ it('should return false for 0', () => {
24
+ assert.equal(toBoolean(0), false)
25
+ })
26
+
27
+ it('should return false for an empty string', () => {
28
+ assert.equal(toBoolean(''), false)
29
+ })
30
+
31
+ it('should return false for null', () => {
32
+ assert.equal(toBoolean(null), false)
33
+ })
34
+
35
+ it('should return undefined for undefined', () => {
36
+ assert.equal(toBoolean(undefined), undefined)
37
+ })
38
+
39
+ it('should return false for a non-"true" string', () => {
40
+ assert.equal(toBoolean('yes'), false)
41
+ })
42
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, it } 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 { writeJson } from '../lib/utils/writeJson.js'
8
+
9
+ describe('writeJson()', () => {
10
+ it('should write formatted JSON to a file', async () => {
11
+ const tmpFile = path.join(os.tmpdir(), `writeJson-test-${Date.now()}.json`)
12
+ const data = { name: 'test', items: [1, 2, 3] }
13
+ try {
14
+ await writeJson(tmpFile, data)
15
+ const content = await fs.readFile(tmpFile, 'utf-8')
16
+ assert.equal(content, JSON.stringify(data, null, 2))
17
+ } finally {
18
+ await fs.unlink(tmpFile).catch(() => {})
19
+ }
20
+ })
21
+
22
+ it('should overwrite an existing file', async () => {
23
+ const tmpFile = path.join(os.tmpdir(), `writeJson-overwrite-${Date.now()}.json`)
24
+ try {
25
+ await writeJson(tmpFile, { old: true })
26
+ await writeJson(tmpFile, { new: true })
27
+ const content = JSON.parse(await fs.readFile(tmpFile, 'utf-8'))
28
+ assert.equal(content.new, true)
29
+ assert.equal(content.old, undefined)
30
+ } finally {
31
+ await fs.unlink(tmpFile).catch(() => {})
32
+ }
33
+ })
34
+ })
@@ -1,80 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import Utils from '../lib/Utils.js'
4
-
5
- describe('Utils', () => {
6
- describe('.metadataFileName', () => {
7
- it('should return the metadata file name', () => {
8
- assert.equal(Utils.metadataFileName, 'adapt-authoring.json')
9
- })
10
- })
11
-
12
- describe('.packageFileName', () => {
13
- it('should return the package file name', () => {
14
- assert.equal(Utils.packageFileName, 'package.json')
15
- })
16
- })
17
-
18
- describe('.getArgs()', () => {
19
- it('should return an object with parsed arguments', () => {
20
- const args = Utils.getArgs()
21
- assert.equal(typeof args, 'object')
22
- assert.ok(Array.isArray(args.params))
23
- })
24
-
25
- it('should include the underscore array from minimist', () => {
26
- const args = Utils.getArgs()
27
- assert.ok(Array.isArray(args._))
28
- })
29
-
30
- it('should derive params by slicing first two entries from _', () => {
31
- const args = Utils.getArgs()
32
- assert.deepEqual(args.params, args._.slice(2))
33
- })
34
- })
35
-
36
- describe('.isObject()', () => {
37
- const validObjects = [
38
- { value: {}, label: 'empty object' },
39
- { value: { key: 'value' }, label: 'object with properties' },
40
- { value: { nested: { key: 'value' } }, label: 'nested object' }
41
- ]
42
-
43
- validObjects.forEach(({ value, label }) => {
44
- it(`should return true for ${label}`, () => {
45
- assert.equal(Utils.isObject(value), true)
46
- })
47
- })
48
-
49
- const objectLikeButValid = [
50
- { value: new Date(), label: 'Date instance' },
51
- { value: /regex/, label: 'RegExp instance' }
52
- ]
53
-
54
- objectLikeButValid.forEach(({ value, label }) => {
55
- it(`should return true for ${label}`, () => {
56
- assert.equal(Utils.isObject(value), true)
57
- })
58
- })
59
-
60
- const invalidObjects = [
61
- { value: null, label: 'null' },
62
- { value: [], label: 'empty array' },
63
- { value: [1, 2, 3], label: 'array with values' },
64
- { value: 'string', label: 'string' },
65
- { value: 123, label: 'number' },
66
- { value: true, label: 'boolean' },
67
- { value: undefined, label: 'undefined' },
68
- { value: () => {}, label: 'function' },
69
- { value: 0, label: 'zero' },
70
- { value: '', label: 'empty string' },
71
- { value: NaN, label: 'NaN' }
72
- ]
73
-
74
- invalidObjects.forEach(({ value, label }) => {
75
- it(`should return false for ${label}`, () => {
76
- assert.equal(Utils.isObject(value), false)
77
- })
78
- })
79
- })
80
- })