@aria-framework/testkit 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/index.js +25 -21
  2. package/package.json +21 -20
  3. package/tempDb.js +167 -0
package/index.js CHANGED
@@ -1,21 +1,25 @@
1
- /**
2
- * @aria-framework/testkit — test infrastructure for the aria apps. A DEV DEPENDENCY: nothing here
3
- * belongs in a production install, which is why it is its own package rather than part of kit.
4
- *
5
- * createHarness() — the ✓/✗ micro-harness (~111 hand-rolled copies replaced);
6
- * check() refuses async callbacks, acheck() awaits them,
7
- * done() prints the summary and owns process.exit.
8
- * assertImportsInScope({...}) — every consumer of a module has every function it calls
9
- * in scope (the silently-empty-list-in-a-catch class).
10
- * assertVersionSingleSource({...}) — package.json vs derived version files vs the lockfile's
11
- * two copies.
12
- */
13
-
14
- 'use strict';
15
-
16
- module.exports = Object.assign(
17
- {},
18
- require('./harness'),
19
- require('./importsInScope'),
20
- require('./versionSync')
21
- );
1
+ /**
2
+ * @aria-framework/testkit — test infrastructure for the aria apps. A DEV DEPENDENCY: nothing here
3
+ * belongs in a production install, which is why it is its own package rather than part of kit.
4
+ *
5
+ * createHarness() — the ✓/✗ micro-harness (~111 hand-rolled copies replaced);
6
+ * check() refuses async callbacks, acheck() awaits them,
7
+ * done() prints the summary and owns process.exit.
8
+ * assertImportsInScope({...}) — every consumer of a module has every function it calls
9
+ * in scope (the silently-empty-list-in-a-catch class).
10
+ * assertVersionSingleSource({...}) — package.json vs derived version files vs the lockfile's
11
+ * two copies.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ module.exports = Object.assign(
17
+ {},
18
+ require('./harness'),
19
+ require('./importsInScope'),
20
+ require('./versionSync'),
21
+ // copyDatabase / assertIsCopy / sweep — a private copy of the app's database for one test run,
22
+ // which clears whatever an earlier run with this pid left behind and takes itself away
23
+ // afterwards. See tempDb.js for the flake that made it necessary.
24
+ require('./tempDb')
25
+ );
package/package.json CHANGED
@@ -1,20 +1,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.1.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
- ],
17
- "scripts": {
18
- "test": "node test/smoke.js"
19
- }
20
- }
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
+ }
package/tempDb.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A private copy of the app's database, for one test run, that takes itself away afterwards.
3
+ *
4
+ * ── WHY A COPY AT ALL ───────────────────────────────────────────────────────────────────────────
5
+ * A test that opens the live database can destroy the developer's own data, and worse, can pass for
6
+ * the wrong reason: it sees rows nobody put there. Every suite here therefore works on a copy — and
7
+ * the copy is named after the process id so two runs cannot fight over one file.
8
+ *
9
+ * ── THE BUG THIS EXISTS TO END ──────────────────────────────────────────────────────────────────
10
+ * That naming scheme has a tail. The file is never removed, so leftovers accumulate — one repository
11
+ * reached 15,598 of them — and eventually the OS hands out a pid whose file is still lying there
12
+ * from a run weeks ago. The naive copy loop only writes the sidecars that exist for the LIVE
13
+ * database, so a stale `-wal` from that earlier run survives beside the fresh `.db`, and SQLite
14
+ * dutifully replays it: the previous run's rows come back inside what is supposed to be a clean
15
+ * copy. It surfaced as `UNIQUE constraint failed` on an id also keyed to the pid, and as
16
+ * `malformed database schema (ai_usage) - invalid rootpage`.
17
+ *
18
+ * It fails perhaps one run in a hundred and passes on an immediate retry with a different pid,
19
+ * which is the worst shape a flake can have: rare enough to be dismissed, frequent enough to erode
20
+ * trust in every other failure the suite reports.
21
+ *
22
+ * So two things, and the FIRST is the one that actually fixes it:
23
+ *
24
+ * 1. CLEAR THE TARGET FIRST, sidecars included, before copying anything onto it. This is the fix.
25
+ * It holds however the previous run ended — killed, crashed, or tidy — because it makes no
26
+ * assumption about cleanup having happened at all.
27
+ * 2. Remove it on exit, so the pool of leftovers stops growing. This is housekeeping, and it is
28
+ * BEST EFFORT BY NECESSITY.
29
+ *
30
+ * ── WHY CLEANUP CANNOT BE RELIED ON ─────────────────────────────────────────────────────────────
31
+ * On Windows, unlinking a file that is still open fails with EBUSY, and an exit handler runs while
32
+ * the driver still holds the database. So a test that never closes its connection — which is most
33
+ * of them, because the process is about to end anyway — leaves its copy behind no matter what is
34
+ * registered. Pass `close` to fix that for one caller; run `sweep()` from a pretest step to bound
35
+ * the pool for all of them. Neither is load-bearing: step 1 is.
36
+ *
37
+ * ── WHAT THIS DOES NOT DO ───────────────────────────────────────────────────────────────────────
38
+ * It does not open the database. Which driver, which migrations and which assertions belong to the
39
+ * app; this only decides where the file goes and guarantees it is clean and temporary.
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ const fs = require('fs');
45
+ const os = require('os');
46
+ const path = require('path');
47
+
48
+ /** SQLite writes two sidecars beside the database, and both carry state. */
49
+ const SUFFIXES = ['', '-wal', '-shm'];
50
+
51
+ function removeAll(base) {
52
+ for (const suffix of SUFFIXES) {
53
+ try { fs.unlinkSync(base + suffix); } catch (e) { /* absent, or held open elsewhere */ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Copy `livePath` to a private temp file and return its path.
59
+ *
60
+ * @param {object} o
61
+ * livePath the database to copy (required)
62
+ * name a short label, so leftovers can be traced to the test that made them
63
+ * dir where to put it; defaults to the OS temp directory
64
+ * prefix default 'app' — apps pass their own so two projects cannot collide
65
+ * cleanup default true; false leaves the copy behind for inspection after a failure
66
+ * close optional () => void, run before deleting. On Windows an open handle makes unlink
67
+ * fail with EBUSY, so without this the exit hook is a no-op for any test that does not
68
+ * close its database — which is most of them.
69
+ */
70
+ function copyDatabase(o = {}) {
71
+ const live = o.livePath;
72
+ 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}`);
74
+
75
+ const label = String(o.name || 'test').replace(/[^a-z0-9-]+/gi, '-');
76
+ const copy = path.join(o.dir || os.tmpdir(),
77
+ `${o.prefix || 'app'}-${label}-${process.pid}.db`);
78
+
79
+ // 1. THE TARGET, GONE — including sidecars a previous run with this pid may have left.
80
+ removeAll(copy);
81
+
82
+ // 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);
86
+ }
87
+
88
+ // 3. And take it away afterwards. Best effort by nature: a process that is killed runs no exit
89
+ // handler at all, which is exactly why step 1 cannot be skipped.
90
+ if (o.cleanup !== false) {
91
+ process.on('exit', () => {
92
+ // CLOSE BEFORE DELETING, where the caller can. Windows refuses to unlink an open file, and
93
+ // 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
+ }
97
+ removeAll(copy);
98
+ });
99
+ }
100
+
101
+ return copy;
102
+ }
103
+
104
+ /**
105
+ * A path for a database this test will BUILD, rather than copy — cleared and temporary.
106
+ *
107
+ * The same pid-keyed name, and therefore the same trap by a shorter route: a test that runs
108
+ * migrations onto a fresh file will happily do so beside a `-wal` left by an earlier run with this
109
+ * pid, and SQLite replays it into the new database. The main file being absent is no protection at
110
+ * all — it is the SIDECAR that carries the old rows.
111
+ *
112
+ * Returns the path. Nothing is created here; the caller's driver does that.
113
+ */
114
+ function freshDatabase(o = {}) {
115
+ const label = String(o.name || 'test').replace(/[^a-z0-9-]+/gi, '-');
116
+ const file = path.join(o.dir || os.tmpdir(),
117
+ `${o.prefix || 'app'}-${label}-${process.pid}.db`);
118
+ removeAll(file);
119
+ if (o.cleanup !== false) {
120
+ process.on('exit', () => {
121
+ if (typeof o.close === 'function') {
122
+ try { o.close(); } catch (e) { /* already closed, or never opened */ }
123
+ }
124
+ removeAll(file);
125
+ });
126
+ }
127
+ return file;
128
+ }
129
+
130
+ /**
131
+ * Prove that what the app opened is the copy and NOT the live database.
132
+ *
133
+ * Worth its own function because the consequence of getting it wrong is silent and permanent: a
134
+ * suite that mutates live data looks exactly like a suite that passes.
135
+ *
136
+ * @param {string} openFile what the driver reports it has open (PRAGMA database_list)
137
+ */
138
+ function assertIsCopy(openFile, copyPath, livePath) {
139
+ const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
140
+ if (!openFile) throw new Error('assertIsCopy: the driver did not say which file it opened');
141
+ if (livePath && same(openFile, livePath)) {
142
+ throw new Error(`THIS IS THE LIVE DATABASE (${openFile}) — the test would mutate real data`);
143
+ }
144
+ if (!same(openFile, copyPath)) {
145
+ throw new Error(`opened ${openFile}, expected the copy at ${copyPath}`);
146
+ }
147
+ return true;
148
+ }
149
+
150
+ /** Sweep leftovers from earlier runs — for a housekeeping script, never for a test. */
151
+ function sweep(o = {}) {
152
+ const dir = o.dir || os.tmpdir();
153
+ const prefix = o.prefix || 'app';
154
+ let removed = 0;
155
+ let names;
156
+ try { names = fs.readdirSync(dir); } catch (e) { return { removed: 0 }; }
157
+ for (const name of names) {
158
+ if (!name.startsWith(prefix + '-') || !/\.db(-wal|-shm)?$/.test(name)) continue;
159
+ // NEVER THIS RUN'S OWN COPY. A sweep that deletes the file the caller is using would turn a
160
+ // housekeeping convenience into the very corruption it exists to prevent.
161
+ if (name.includes('-' + process.pid + '.db')) continue;
162
+ try { fs.unlinkSync(path.join(dir, name)); removed += 1; } catch (e) { /* held open */ }
163
+ }
164
+ return { removed };
165
+ }
166
+
167
+ module.exports = { copyDatabase, freshDatabase, assertIsCopy, sweep, _suffixes: SUFFIXES };