@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 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 \u2014 test infrastructure. The \u2713/\u2717 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.2.0",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "main": "index.js",
11
- "files": [
12
- "index.js",
13
- "harness.js",
14
- "importsInScope.js",
15
- "versionSync.js",
16
- "tempDb.js"
17
- ],
18
- "scripts": {
19
- "test": "node test/smoke.js && node test/tempDb.js"
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
- if (!fs.existsSync(live)) throw new Error(`copyDatabase: there is no database at ${live}`);
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
- for (const suffix of SUFFIXES) {
84
- const src = live + suffix;
85
- if (fs.existsSync(src)) fs.copyFileSync(src, copy + suffix);
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
- if (typeof o.close === 'function') {
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
- if (typeof o.close === 'function') {
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
  }