adapt-authoring-ui 1.8.6 → 1.9.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.
@@ -0,0 +1,15 @@
1
+ name: Tests
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
package/lib/UiBuild.js CHANGED
@@ -60,7 +60,7 @@ class UiBuild {
60
60
  this.preBuildHook = new Hook()
61
61
  this.postBuildHook = new Hook()
62
62
 
63
- this.jsTask = new JavaScriptTask(this.Paths.Output, this.log, this.uiPlugins, this.app.getConfig('tempDir'))
63
+ this.jsTask = new JavaScriptTask(this.Paths.Output, this.log, this.uiPlugins, path.join(this.app.getConfig('tempDir'), 'ui-build-cache'))
64
64
  }
65
65
 
66
66
  collate (collateAtFolderName, destFolder, srcFileName) {
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "adapt-authoring-ui",
3
- "version": "1.8.6",
3
+ "version": "1.9.0",
4
4
  "description": "Front-end application for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-ui",
6
6
  "license": "GPL-3.0",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "scripts": {
10
- "postinstall": "node npm_hooks/postinstall.js"
10
+ "postinstall": "node npm_hooks/postinstall.js",
11
+ "test": "node --test 'tests/**/*.spec.js'"
11
12
  },
12
13
  "repository": "github:adapt-security/adapt-authoring-ui",
13
14
  "dependencies": {
@@ -0,0 +1,170 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import crypto from 'crypto'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import fs from 'fs-extra'
7
+ import CacheManager from '../lib/CacheManager.js'
8
+
9
+ describe('CacheManager', () => {
10
+ describe('constructor', () => {
11
+ it('should set maxAge to the provided value', () => {
12
+ const cm = new CacheManager({ maxAge: 1000, logger: { log () {} } })
13
+ assert.equal(cm.maxAge, 1000)
14
+ })
15
+
16
+ it('should use ONE_WEEK as default maxAge', () => {
17
+ const ONE_WEEK = 7 * 24 * 60 * 60 * 1000
18
+ const cm = new CacheManager({ logger: { log () {} } })
19
+ assert.equal(cm.maxAge, ONE_WEEK)
20
+ })
21
+
22
+ it('should set the provided logger', () => {
23
+ const logger = { log () {} }
24
+ const cm = new CacheManager({ logger })
25
+ assert.equal(cm.logger, logger)
26
+ })
27
+
28
+ it('should use a default logger when none is provided', () => {
29
+ const cm = new CacheManager()
30
+ assert.equal(typeof cm.logger.log, 'function')
31
+ })
32
+
33
+ it('should use provided tempDir', () => {
34
+ const tempDir = path.join(os.tmpdir(), 'adapt-authoring-test-custom')
35
+ const cm = new CacheManager({ tempDir, logger: { log () {} } })
36
+ assert.equal(cm.tempDir, tempDir)
37
+ // Cleanup
38
+ try { fs.rmdirSync(tempDir) } catch (e) {}
39
+ })
40
+
41
+ it('should use default tempDir when none is provided', () => {
42
+ const cm = new CacheManager({ logger: { log () {} } })
43
+ assert.equal(cm.tempDir, path.join(os.tmpdir(), 'adapt-authoring'))
44
+ })
45
+
46
+ it('should ensure tempDir directory exists', () => {
47
+ const cm = new CacheManager({ logger: { log () {} } })
48
+ assert.ok(fs.existsSync(cm.tempDir))
49
+ })
50
+ })
51
+
52
+ describe('static hash', () => {
53
+ it('should return a SHA1 hex digest of the input', () => {
54
+ const input = '/some/path/to/file'
55
+ const expected = crypto
56
+ .createHash('sha1')
57
+ .update(input, 'utf8')
58
+ .digest('hex')
59
+ assert.equal(CacheManager.hash(input), expected)
60
+ })
61
+
62
+ it('should return different hashes for different inputs', () => {
63
+ const hash1 = CacheManager.hash('/path/one')
64
+ const hash2 = CacheManager.hash('/path/two')
65
+ assert.notEqual(hash1, hash2)
66
+ })
67
+
68
+ it('should return the same hash for the same input', () => {
69
+ const hash1 = CacheManager.hash('/same/path')
70
+ const hash2 = CacheManager.hash('/same/path')
71
+ assert.equal(hash1, hash2)
72
+ })
73
+
74
+ it('should return a 40-character hex string', () => {
75
+ const hash = CacheManager.hash('test')
76
+ assert.match(hash, /^[0-9a-f]{40}$/)
77
+ })
78
+ })
79
+
80
+ describe('cachePath', () => {
81
+ it('should return a .cache file path under tempDir', () => {
82
+ const cm = new CacheManager({ logger: { log () {} } })
83
+ const result = cm.cachePath('/base', '/output')
84
+ assert.ok(result.startsWith(cm.tempDir))
85
+ assert.ok(result.endsWith('.cache'))
86
+ })
87
+
88
+ it('should incorporate both basePath and outputFilePath into the hash', () => {
89
+ const cm = new CacheManager({ logger: { log () {} } })
90
+ const result1 = cm.cachePath('/base1', '/output')
91
+ const result2 = cm.cachePath('/base2', '/output')
92
+ assert.notEqual(result1, result2)
93
+ })
94
+
95
+ it('should use process.cwd() as default outputFilePath', () => {
96
+ const cm = new CacheManager({ logger: { log () {} } })
97
+ const expectedHash = CacheManager.hash(path.join('/base', process.cwd()))
98
+ const expected = path.join(cm.tempDir, `${expectedHash}.cache`)
99
+ assert.equal(cm.cachePath('/base'), expected)
100
+ })
101
+
102
+ it('should produce a deterministic result for same inputs', () => {
103
+ const cm = new CacheManager({ logger: { log () {} } })
104
+ const result1 = cm.cachePath('/base', '/output')
105
+ const result2 = cm.cachePath('/base', '/output')
106
+ assert.equal(result1, result2)
107
+ })
108
+ })
109
+
110
+ describe('checkFilePath', () => {
111
+ it('should return a last.touch file path under tempDir', () => {
112
+ const cm = new CacheManager({ logger: { log () {} } })
113
+ assert.equal(cm.checkFilePath, path.join(cm.tempDir, 'last.touch'))
114
+ })
115
+ })
116
+
117
+ describe('isCleaningTime', () => {
118
+ let cm
119
+
120
+ beforeEach(() => {
121
+ cm = new CacheManager({ maxAge: 1000, logger: { log () {} } })
122
+ })
123
+
124
+ it('should return true when checkFile does not exist', async () => {
125
+ const checkPath = cm.checkFilePath
126
+ try { fs.unlinkSync(checkPath) } catch (e) {}
127
+ const result = await cm.isCleaningTime()
128
+ assert.equal(result, true)
129
+ })
130
+
131
+ it('should return false when checkFile was just created', async () => {
132
+ const checkPath = cm.checkFilePath
133
+ fs.writeFileSync(checkPath, String(Date.now()))
134
+ const result = await cm.isCleaningTime()
135
+ assert.equal(result, false)
136
+ })
137
+ })
138
+
139
+ describe('clean', () => {
140
+ let cm
141
+
142
+ beforeEach(() => {
143
+ // Use a large maxAge so the checkFile is not treated as expired
144
+ cm = new CacheManager({ maxAge: 7 * 24 * 60 * 60 * 1000, logger: { log () {} } })
145
+ fs.ensureDirSync(cm.tempDir)
146
+ // Remove checkFile to ensure cleaning happens
147
+ try { fs.unlinkSync(cm.checkFilePath) } catch (e) {}
148
+ })
149
+
150
+ it('should create checkFile after cleaning', async () => {
151
+ await cm.clean()
152
+ assert.ok(fs.existsSync(cm.checkFilePath))
153
+ })
154
+
155
+ it('should not clean when it is not cleaning time', async () => {
156
+ // First write the checkFile so it appears fresh
157
+ fs.writeFileSync(cm.checkFilePath, String(Date.now()))
158
+ // Set a long maxAge so checkInterval is large
159
+ cm.maxAge = 7 * 24 * 60 * 60 * 1000
160
+ // Create a test file
161
+ const testFile = path.join(cm.tempDir, 'test-no-clean.cache')
162
+ fs.writeFileSync(testFile, 'data')
163
+ await cm.clean()
164
+ // File should still exist since it is not cleaning time
165
+ assert.ok(fs.existsSync(testFile))
166
+ // Cleanup
167
+ try { fs.unlinkSync(testFile) } catch (e) {}
168
+ })
169
+ })
170
+ })
@@ -0,0 +1,247 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'fs-extra'
4
+
5
+ /**
6
+ * JavaScriptTask depends on rollup, babel, glob, fs-extra and other heavy
7
+ * build dependencies. We test the pure utility methods in isolation by
8
+ * reimplementing them with the same logic.
9
+ */
10
+
11
+ /**
12
+ * Reimplementation of JavaScriptTask.prototype.escapeRegExp for isolated testing.
13
+ */
14
+ function escapeRegExp (string) {
15
+ return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&')
16
+ }
17
+
18
+ describe('JavaScriptTask', () => {
19
+ describe('escapeRegExp', () => {
20
+ it('should escape dots', () => {
21
+ assert.equal(escapeRegExp('file.js'), 'file\\.js')
22
+ })
23
+
24
+ it('should escape asterisks', () => {
25
+ assert.equal(escapeRegExp('**/*.js'), '\\*\\*/\\*\\.js')
26
+ })
27
+
28
+ it('should escape plus signs', () => {
29
+ assert.equal(escapeRegExp('a+b'), 'a\\+b')
30
+ })
31
+
32
+ it('should escape question marks', () => {
33
+ assert.equal(escapeRegExp('file?.js'), 'file\\?\\.js')
34
+ })
35
+
36
+ it('should escape caret', () => {
37
+ assert.equal(escapeRegExp('^start'), '\\^start')
38
+ })
39
+
40
+ it('should escape dollar sign', () => {
41
+ assert.equal(escapeRegExp('end$'), 'end\\$')
42
+ })
43
+
44
+ it('should escape curly braces', () => {
45
+ assert.equal(escapeRegExp('{a,b}'), '\\{a,b\\}')
46
+ })
47
+
48
+ it('should escape parentheses', () => {
49
+ assert.equal(escapeRegExp('(group)'), '\\(group\\)')
50
+ })
51
+
52
+ it('should escape pipe', () => {
53
+ assert.equal(escapeRegExp('a|b'), 'a\\|b')
54
+ })
55
+
56
+ it('should escape square brackets', () => {
57
+ assert.equal(escapeRegExp('[abc]'), '\\[abc\\]')
58
+ })
59
+
60
+ it('should escape backslashes', () => {
61
+ assert.equal(escapeRegExp('path\\to'), 'path\\\\to')
62
+ })
63
+
64
+ it('should escape hyphen/minus', () => {
65
+ assert.equal(escapeRegExp('a-b'), 'a\\-b')
66
+ })
67
+
68
+ it('should return the same string if no special chars', () => {
69
+ assert.equal(escapeRegExp('hello world'), 'hello world')
70
+ })
71
+
72
+ it('should handle empty string', () => {
73
+ assert.equal(escapeRegExp(''), '')
74
+ })
75
+
76
+ it('should handle multiple special characters together', () => {
77
+ const input = 'C:\\Users\\test\\file.js'
78
+ const expected = 'C:\\\\Users\\\\test\\\\file\\.js'
79
+ assert.equal(escapeRegExp(input), expected)
80
+ })
81
+
82
+ it('should produce a string usable in new RegExp without error', () => {
83
+ const special = 'path/to/file.*(test)+[0]'
84
+ const escaped = escapeRegExp(special)
85
+ assert.doesNotThrow(() => new RegExp(escaped))
86
+ })
87
+
88
+ it('should produce a regex that matches the original string literally', () => {
89
+ const special = 'hello.world+foo*bar'
90
+ const escaped = escapeRegExp(special)
91
+ const regex = new RegExp(escaped)
92
+ assert.ok(regex.test(special))
93
+ })
94
+ })
95
+
96
+ describe('checkCache', () => {
97
+ /**
98
+ * Reimplementation of JavaScriptTask.prototype.checkCache for isolated testing.
99
+ * Uses a context object instead of `this`.
100
+ */
101
+ function checkCache (context, invalidate) {
102
+ if (!context.cache) return
103
+ const idHash = {}
104
+ const missing = {}
105
+ context.cache.modules.forEach(mod => {
106
+ const moduleId = mod.id
107
+ const isRollupHelper = (moduleId[0] === '\u0000')
108
+ if (isRollupHelper) {
109
+ return null
110
+ }
111
+ if (!fs.existsSync(moduleId)) {
112
+ context.log('error', `Cache missing file: ${moduleId.replace(context.cwd, '')}`)
113
+ missing[moduleId] = true
114
+ return false
115
+ }
116
+ if (invalidate && invalidate.includes(moduleId)) {
117
+ context.log('debug', `Cache skipping file: ${moduleId.replace(context.cwd, '')}`)
118
+ return false
119
+ }
120
+ idHash[moduleId] = mod
121
+ return true
122
+ })
123
+ if (Object.keys(missing).length) {
124
+ context.cache = null
125
+ return
126
+ }
127
+ context.cache.modules = Object.values(idHash)
128
+ }
129
+
130
+ it('should return early when cache is null', () => {
131
+ const context = { cache: null, log () {} }
132
+ checkCache(context, [])
133
+ assert.equal(context.cache, null)
134
+ })
135
+
136
+ it('should clear cache when a module file is missing', () => {
137
+ const context = {
138
+ cache: {
139
+ modules: [
140
+ { id: '/nonexistent/path/to/module.js' }
141
+ ]
142
+ },
143
+ cwd: process.cwd() + '/',
144
+ log () {}
145
+ }
146
+ checkCache(context, [])
147
+ assert.equal(context.cache, null)
148
+ })
149
+
150
+ it('should skip rollup helper modules (prefixed with null char)', () => {
151
+ // Create a real file so the non-helper module passes the existsSync check
152
+ const existingFile = import.meta.url.replace('file://', '').replace(/\/[^/]+$/, '/JavaScriptTask.spec.js')
153
+ const context = {
154
+ cache: {
155
+ modules: [
156
+ { id: '\u0000rollupHelper' },
157
+ { id: existingFile }
158
+ ]
159
+ },
160
+ cwd: '',
161
+ log () {}
162
+ }
163
+ checkCache(context, [])
164
+ // Cache should not be null since the real file exists
165
+ assert.notEqual(context.cache, null)
166
+ // Only the real file should remain (rollup helper is skipped, not added to idHash)
167
+ assert.equal(context.cache.modules.length, 1)
168
+ assert.equal(context.cache.modules[0].id, existingFile)
169
+ })
170
+
171
+ it('should remove invalidated modules from cache', () => {
172
+ const existingFile = import.meta.url.replace('file://', '').replace(/\/[^/]+$/, '/JavaScriptTask.spec.js')
173
+ const context = {
174
+ cache: {
175
+ modules: [
176
+ { id: existingFile }
177
+ ]
178
+ },
179
+ cwd: '',
180
+ log () {}
181
+ }
182
+ checkCache(context, [existingFile])
183
+ assert.notEqual(context.cache, null)
184
+ assert.equal(context.cache.modules.length, 0)
185
+ })
186
+
187
+ it('should keep valid non-invalidated modules', () => {
188
+ const existingFile = import.meta.url.replace('file://', '').replace(/\/[^/]+$/, '/JavaScriptTask.spec.js')
189
+ const context = {
190
+ cache: {
191
+ modules: [
192
+ { id: existingFile }
193
+ ]
194
+ },
195
+ cwd: '',
196
+ log () {}
197
+ }
198
+ checkCache(context, [])
199
+ assert.notEqual(context.cache, null)
200
+ assert.equal(context.cache.modules.length, 1)
201
+ })
202
+ })
203
+
204
+ describe('logPrettyError', () => {
205
+ it('should handle errors with loc and babel plugin', () => {
206
+ const err = new Error('babel: Unexpected token\n 1 | code here\n | ^')
207
+ err.loc = { line: 1, column: 5 }
208
+ err.plugin = 'babel'
209
+ err.id = '/Users/test/project/src/file.js'
210
+
211
+ // Extract babel error handling logic from logPrettyError
212
+ err.frame = err.message.substr(err.message.indexOf('\n') + 1)
213
+ err.message = err.message.substr(0, err.message.indexOf('\n')).slice(2).replace(/^([^:]*): /, '')
214
+
215
+ assert.equal(err.message, 'Unexpected token')
216
+ assert.ok(err.frame.includes('code here'))
217
+ })
218
+
219
+ it('should handle errors without loc property', () => {
220
+ const err = new Error('Generic build error')
221
+ const hasLoc = Boolean(err.loc)
222
+ assert.equal(hasLoc, false)
223
+ })
224
+
225
+ it('should handle errors with loc but non-babel plugin', () => {
226
+ const logged = []
227
+ const log = (level, ...args) => logged.push({ level, args })
228
+ const err = new Error('Some plugin error')
229
+ err.loc = { line: 5, column: 10 }
230
+ err.plugin = 'other-plugin'
231
+ err.id = '/test/src/file.js'
232
+
233
+ // Non-babel plugin path logs err.toString() in the default case
234
+ let hasOutput = false
235
+ switch (err.plugin) {
236
+ case 'babel':
237
+ break
238
+ default:
239
+ hasOutput = true
240
+ log('error', err.toString())
241
+ }
242
+ assert.equal(hasOutput, true)
243
+ assert.equal(logged.length, 1)
244
+ assert.equal(logged[0].level, 'error')
245
+ })
246
+ })
247
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import path from 'upath'
4
+
5
+ /**
6
+ * UiBuild depends on the full app context (adapt-authoring-core Hook, app object,
7
+ * gaze, rollup, etc.). We extract and test the pure utility methods in isolation
8
+ * by reimplementing them with the same logic as UiBuild.prototype methods.
9
+ */
10
+
11
+ /**
12
+ * Reimplementation of UiBuild.prototype.collate for isolated testing.
13
+ * Computes an output path from a collateAtFolderName, destFolder, and srcFileName.
14
+ */
15
+ function collate (collateAtFolderName, destFolder, srcFileName) {
16
+ const nameParts = srcFileName.split('/')
17
+ if (nameParts[nameParts.length - 1] === collateAtFolderName) {
18
+ return destFolder
19
+ }
20
+ const startOfCollatePath = srcFileName.indexOf(collateAtFolderName) + collateAtFolderName.length + 1
21
+ return path.join(destFolder, srcFileName.substr(startOfCollatePath))
22
+ }
23
+
24
+ describe('UiBuild', () => {
25
+ describe('collate', () => {
26
+ it('should return destFolder when srcFileName ends with collateAtFolderName', () => {
27
+ const result = collate('assets', '/output/assets', 'some/path/assets')
28
+ assert.equal(result, '/output/assets')
29
+ })
30
+
31
+ it('should extract the path after collateAtFolderName and join with destFolder', () => {
32
+ const result = collate('assets', '/output/assets', 'some/path/assets/images/logo.png')
33
+ assert.equal(result, '/output/assets/images/logo.png')
34
+ })
35
+
36
+ it('should handle nested folder structures correctly', () => {
37
+ const result = collate('required', '/build', 'app/core/required/config.json')
38
+ assert.equal(result, '/build/config.json')
39
+ })
40
+
41
+ it('should handle deeply nested paths after collateAtFolderName', () => {
42
+ const result = collate('libraries', '/out/libs', 'app/libraries/vendor/jquery/jquery.min.js')
43
+ assert.equal(result, '/out/libs/vendor/jquery/jquery.min.js')
44
+ })
45
+
46
+ it('should handle single file after collateAtFolderName', () => {
47
+ const result = collate('assets', '/dest', 'module/assets/file.txt')
48
+ assert.equal(result, '/dest/file.txt')
49
+ })
50
+ })
51
+ })