@aria-framework/testkit 0.2.0 → 0.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.
- package/index.js +4 -0
- package/installedMatchesLockfile.js +99 -0
- package/package.json +22 -21
- package/tempDb.js +58 -10
package/index.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* in scope (the silently-empty-list-in-a-catch class).
|
|
10
10
|
* assertVersionSingleSource({...}) — package.json vs derived version files vs the lockfile's
|
|
11
11
|
* two copies.
|
|
12
|
+
* assertInstalledMatchesLockfile({...}) — what is in node_modules is what `npm ci` would install.
|
|
13
|
+
* The hand-copied-package class: green locally, different
|
|
14
|
+
* code on the server, no error either side.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
'use strict';
|
|
@@ -18,6 +21,7 @@ module.exports = Object.assign(
|
|
|
18
21
|
require('./harness'),
|
|
19
22
|
require('./importsInScope'),
|
|
20
23
|
require('./versionSync'),
|
|
24
|
+
require('./installedMatchesLockfile'),
|
|
21
25
|
// copyDatabase / assertIsCopy / sweep — a private copy of the app's database for one test run,
|
|
22
26
|
// which clears whatever an earlier run with this pid left behind and takes itself away
|
|
23
27
|
// afterwards. See tempDb.js for the flake that made it necessary.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertInstalledMatchesLockfile — what is ON DISK is what `npm ci` would install.
|
|
3
|
+
*
|
|
4
|
+
* ── THE FAILURE THIS EXISTS FOR ─────────────────────────────────────────────────────────────────
|
|
5
|
+
* A framework package is edited in its own checkout and hand-copied into an app's node_modules.
|
|
6
|
+
* Everything local goes green against it. The lockfile still pins the PUBLISHED version, and the
|
|
7
|
+
* documented deploy is `git pull && npm ci --omit=dev` — which installs the lockfile's version, not
|
|
8
|
+
* the one every test just passed against.
|
|
9
|
+
*
|
|
10
|
+
* It happened, it was not small, and it was invisible: @aria-framework/ai sat at 0.18.1 in
|
|
11
|
+
* node_modules and 0.18.0 in the lockfile, and the ONLY file that differed between them was
|
|
12
|
+
* untrusted.js — the prompt-injection fence the consuming app's hardening was built on. 0.18.0's
|
|
13
|
+
* rule pointed the model at the wrong text. Production would have run a fence that says "below"
|
|
14
|
+
* about a block placed above, while a suite asserting the opposite passed on every machine that
|
|
15
|
+
* had the copy.
|
|
16
|
+
*
|
|
17
|
+
* ── WHY A TEST AND NOT A NOTE ───────────────────────────────────────────────────────────────────
|
|
18
|
+
* Because the failure is silent in the direction that matters. Nothing errors: the app runs, the
|
|
19
|
+
* tests pass, and the difference appears only on the server, in behaviour, with no message. A note
|
|
20
|
+
* describing exactly this already existed in one app's project memory, written after the previous
|
|
21
|
+
* time it happened — and it happened again anyway. A note that has been ignored once is a test that
|
|
22
|
+
* had not been written yet.
|
|
23
|
+
*
|
|
24
|
+
* ── WHAT IT DOES NOT CHECK ──────────────────────────────────────────────────────────────────────
|
|
25
|
+
* Whether the lockfile's version exists on the registry. That needs the network, and a check that
|
|
26
|
+
* fails on a train is a check people learn to skip. This compares two files on disk, which is
|
|
27
|
+
* enough to catch the hand-copy — the only way the two can disagree.
|
|
28
|
+
*
|
|
29
|
+
* Sibling of assertVersionSingleSource: same class of problem (a second copy nothing validates),
|
|
30
|
+
* same shape of answer.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{
|
|
40
|
+
* root: string, app root (absolute)
|
|
41
|
+
* scope?: string, package scope to check (default '@aria-framework')
|
|
42
|
+
* lockfile?: string lockfile name (default 'package-lock.json')
|
|
43
|
+
* }} opts
|
|
44
|
+
* @returns {{checked: number}} how many packages were compared — ASSERT ON THIS. A misspelled
|
|
45
|
+
* scope matches nothing and passes, and a check that inspects nothing is also a claim.
|
|
46
|
+
* @throws Error naming every package whose installed version differs from the pin
|
|
47
|
+
*/
|
|
48
|
+
function assertInstalledMatchesLockfile(opts = {}) {
|
|
49
|
+
const root = opts.root;
|
|
50
|
+
if (!root) throw new Error('assertInstalledMatchesLockfile({ root }): the app root is required');
|
|
51
|
+
const scope = opts.scope || '@aria-framework';
|
|
52
|
+
const lockPath = path.join(root, opts.lockfile || 'package-lock.json');
|
|
53
|
+
|
|
54
|
+
let lock;
|
|
55
|
+
try {
|
|
56
|
+
lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error(`could not read ${lockPath}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// BOTH SHAPES OF NAME. A scope is `@aria-framework`, whose keys are `node_modules/@scope/name`;
|
|
62
|
+
// a bare package name is its own key, `node_modules/left-pad`, with nothing after it. Matching
|
|
63
|
+
// only the first form made this return `{ checked: 0 }` and PASS for the second — inspecting
|
|
64
|
+
// nothing while reporting success, which is worse than no check because it is also a claim.
|
|
65
|
+
const nested = `node_modules/${scope}/`;
|
|
66
|
+
const exact = `node_modules/${scope}`;
|
|
67
|
+
const wrong = [];
|
|
68
|
+
let checked = 0;
|
|
69
|
+
|
|
70
|
+
for (const [key, entry] of Object.entries(lock.packages || {})) {
|
|
71
|
+
// STARTSWITH FROM THE FRONT, NOT INCLUDES. A nested copy —
|
|
72
|
+
// node_modules/x/node_modules/@scope/y — is a different resolution with its own pin, and
|
|
73
|
+
// comparing it against the top-level install would report a disagreement that is not one.
|
|
74
|
+
if (!key.startsWith(nested) && key !== exact) continue;
|
|
75
|
+
const name = key.slice('node_modules/'.length);
|
|
76
|
+
checked += 1;
|
|
77
|
+
|
|
78
|
+
let installed;
|
|
79
|
+
try {
|
|
80
|
+
installed = JSON.parse(fs.readFileSync(path.join(root, key, 'package.json'), 'utf8')).version;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
wrong.push(`${name}: pinned ${entry.version}, NOT INSTALLED`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (installed !== entry.version) {
|
|
86
|
+
wrong.push(`${name}: lockfile ${entry.version}, installed ${installed}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (wrong.length) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'node_modules does not match the lockfile, so `npm ci` on the server installs something these '
|
|
93
|
+
+ 'tests never ran against. Publish the package and `npm install` it, or reinstall the pinned '
|
|
94
|
+
+ 'version — do NOT hand-copy a package into node_modules:\n ' + wrong.join('\n '));
|
|
95
|
+
}
|
|
96
|
+
return { checked };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { assertInstalledMatchesLockfile };
|
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@aria-framework/testkit",
|
|
3
|
-
"description": "Aria App Framework
|
|
4
|
-
"version": "0.
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"private": false,
|
|
7
|
-
"publishConfig": {
|
|
8
|
-
"access": "public"
|
|
9
|
-
},
|
|
10
|
-
"main": "index.js",
|
|
11
|
-
"files": [
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"tempDb.js"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@aria-framework/testkit",
|
|
3
|
+
"description": "Aria App Framework — test infrastructure. The ✓/✗ micro test-harness (createHarness, with the async-callback guard that stops a suite silently passing), assertImportsInScope (every consumer of a module has every function it calls in scope), and assertVersionSingleSource (package.json vs derived version files vs the lockfile's two copies). Dev-dependency only: nothing here belongs in a production install.",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"harness.js",
|
|
13
|
+
"importsInScope.js",
|
|
14
|
+
"index.js",
|
|
15
|
+
"installedMatchesLockfile.js",
|
|
16
|
+
"tempDb.js",
|
|
17
|
+
"versionSync.js"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node test/smoke.js && node test/tempDb.js && node test/installedMatchesLockfile.js"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/tempDb.js
CHANGED
|
@@ -54,6 +54,44 @@ function removeAll(base) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Run a caller's close hook, and refuse to be lied to about it.
|
|
59
|
+
*
|
|
60
|
+
* AN EXIT HANDLER CANNOT AWAIT. So a `close` that returns a promise has not closed anything by the
|
|
61
|
+
* time the unlink runs, and on Windows that unlink then fails with EBUSY into a catch nobody reads.
|
|
62
|
+
* The copy is left behind and the suite still passes — which is precisely the shape of bug this
|
|
63
|
+
* whole module exists to end, arriving through the mechanism meant to prevent it.
|
|
64
|
+
*
|
|
65
|
+
* It is worse than a plain failure because an async close can APPEAR to work: `async close() {
|
|
66
|
+
* db.close(); }` runs its body up to the first await synchronously, so a driver written that way
|
|
67
|
+
* cleans up perfectly until somebody adds an await ahead of that line, or the app moves to a driver
|
|
68
|
+
* whose close is genuinely asynchronous. Then every leftover returns, silently, and the commit that
|
|
69
|
+
* introduced the regression is nowhere near the code that breaks.
|
|
70
|
+
*
|
|
71
|
+
* So: say so, loudly, on the one occasion it can still be heard.
|
|
72
|
+
*/
|
|
73
|
+
function closeSynchronously(close, file) {
|
|
74
|
+
if (typeof close !== 'function') return;
|
|
75
|
+
let result;
|
|
76
|
+
try {
|
|
77
|
+
result = close();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// A synchronous throw is fine — already closed, or never opened. The delete still runs.
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (result && typeof result.then === 'function') {
|
|
83
|
+
// Not swallowed: this is the only moment anybody could learn about it.
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.error(
|
|
86
|
+
`testkit: the close hook for ${path.basename(file)} returned a promise. An exit handler `
|
|
87
|
+
+ 'cannot await, so the database is still open when the file is deleted — on Windows that '
|
|
88
|
+
+ 'fails silently and the copy is left behind. Pass a SYNCHRONOUS close: '
|
|
89
|
+
+ 'the driver' + String.fromCharCode(39) + 's own db.close() usually is one, even when the wrapper is not.');
|
|
90
|
+
// ...and do not let it become an unhandled rejection on the way out.
|
|
91
|
+
result.then(undefined, () => {});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
57
95
|
/**
|
|
58
96
|
* Copy `livePath` to a private temp file and return its path.
|
|
59
97
|
*
|
|
@@ -63,6 +101,8 @@ function removeAll(base) {
|
|
|
63
101
|
* dir where to put it; defaults to the OS temp directory
|
|
64
102
|
* prefix default 'app' — apps pass their own so two projects cannot collide
|
|
65
103
|
* cleanup default true; false leaves the copy behind for inspection after a failure
|
|
104
|
+
* allowMissing default false; true means an absent live database copies nothing rather
|
|
105
|
+
* than throwing, for a test that builds its schema from migrations
|
|
66
106
|
* close optional () => void, run before deleting. On Windows an open handle makes unlink
|
|
67
107
|
* fail with EBUSY, so without this the exit hook is a no-op for any test that does not
|
|
68
108
|
* close its database — which is most of them.
|
|
@@ -70,7 +110,17 @@ function removeAll(base) {
|
|
|
70
110
|
function copyDatabase(o = {}) {
|
|
71
111
|
const live = o.livePath;
|
|
72
112
|
if (!live) throw new Error('copyDatabase({ livePath }): the database to copy is required');
|
|
73
|
-
|
|
113
|
+
// STRICT BY DEFAULT. A test that quietly ran against an empty database when it meant to use real
|
|
114
|
+
// data is its own kind of wrong, and silent is the worst way for that to happen. But a suite that
|
|
115
|
+
// BUILDS its schema from migrations is legitimately fine without one, and the hand-rolled loop
|
|
116
|
+
// this replaced tolerated it — `if (exists) copy` simply copied nothing. On a fresh clone, or a CI
|
|
117
|
+
// box that has never booted the app, strictness alone turns those tests into a module-scope crash.
|
|
118
|
+
const missing = !fs.existsSync(live);
|
|
119
|
+
if (missing && !o.allowMissing) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`copyDatabase: there is no database at ${live}. Pass allowMissing:true if this test builds `
|
|
122
|
+
+ 'its own schema and does not need one.');
|
|
123
|
+
}
|
|
74
124
|
|
|
75
125
|
const label = String(o.name || 'test').replace(/[^a-z0-9-]+/gi, '-');
|
|
76
126
|
const copy = path.join(o.dir || os.tmpdir(),
|
|
@@ -80,9 +130,11 @@ function copyDatabase(o = {}) {
|
|
|
80
130
|
removeAll(copy);
|
|
81
131
|
|
|
82
132
|
// 2. ...then the live database and whichever sidecars it actually has.
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
133
|
+
if (!missing) {
|
|
134
|
+
for (const suffix of SUFFIXES) {
|
|
135
|
+
const src = live + suffix;
|
|
136
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, copy + suffix);
|
|
137
|
+
}
|
|
86
138
|
}
|
|
87
139
|
|
|
88
140
|
// 3. And take it away afterwards. Best effort by nature: a process that is killed runs no exit
|
|
@@ -91,9 +143,7 @@ function copyDatabase(o = {}) {
|
|
|
91
143
|
process.on('exit', () => {
|
|
92
144
|
// CLOSE BEFORE DELETING, where the caller can. Windows refuses to unlink an open file, and
|
|
93
145
|
// this handler runs with the database still open unless something closes it.
|
|
94
|
-
|
|
95
|
-
try { o.close(); } catch (e) { /* already closed, or never opened */ }
|
|
96
|
-
}
|
|
146
|
+
closeSynchronously(o.close, copy);
|
|
97
147
|
removeAll(copy);
|
|
98
148
|
});
|
|
99
149
|
}
|
|
@@ -118,9 +168,7 @@ function freshDatabase(o = {}) {
|
|
|
118
168
|
removeAll(file);
|
|
119
169
|
if (o.cleanup !== false) {
|
|
120
170
|
process.on('exit', () => {
|
|
121
|
-
|
|
122
|
-
try { o.close(); } catch (e) { /* already closed, or never opened */ }
|
|
123
|
-
}
|
|
171
|
+
closeSynchronously(o.close, file);
|
|
124
172
|
removeAll(file);
|
|
125
173
|
});
|
|
126
174
|
}
|