@aria-framework/testkit 0.1.0 → 0.3.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 +25 -21
- package/package.json +21 -20
- package/tempDb.js +215 -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
|
|
4
|
-
"version": "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
|
-
|
|
18
|
-
|
|
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.3.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,215 @@
|
|
|
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
|
+
* 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
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Copy `livePath` to a private temp file and return its path.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} o
|
|
99
|
+
* livePath the database to copy (required)
|
|
100
|
+
* name a short label, so leftovers can be traced to the test that made them
|
|
101
|
+
* dir where to put it; defaults to the OS temp directory
|
|
102
|
+
* prefix default 'app' — apps pass their own so two projects cannot collide
|
|
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
|
|
106
|
+
* close optional () => void, run before deleting. On Windows an open handle makes unlink
|
|
107
|
+
* fail with EBUSY, so without this the exit hook is a no-op for any test that does not
|
|
108
|
+
* close its database — which is most of them.
|
|
109
|
+
*/
|
|
110
|
+
function copyDatabase(o = {}) {
|
|
111
|
+
const live = o.livePath;
|
|
112
|
+
if (!live) throw new Error('copyDatabase({ livePath }): the database to copy is required');
|
|
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
|
+
}
|
|
124
|
+
|
|
125
|
+
const label = String(o.name || 'test').replace(/[^a-z0-9-]+/gi, '-');
|
|
126
|
+
const copy = path.join(o.dir || os.tmpdir(),
|
|
127
|
+
`${o.prefix || 'app'}-${label}-${process.pid}.db`);
|
|
128
|
+
|
|
129
|
+
// 1. THE TARGET, GONE — including sidecars a previous run with this pid may have left.
|
|
130
|
+
removeAll(copy);
|
|
131
|
+
|
|
132
|
+
// 2. ...then the live database and whichever sidecars it actually has.
|
|
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
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. And take it away afterwards. Best effort by nature: a process that is killed runs no exit
|
|
141
|
+
// handler at all, which is exactly why step 1 cannot be skipped.
|
|
142
|
+
if (o.cleanup !== false) {
|
|
143
|
+
process.on('exit', () => {
|
|
144
|
+
// CLOSE BEFORE DELETING, where the caller can. Windows refuses to unlink an open file, and
|
|
145
|
+
// this handler runs with the database still open unless something closes it.
|
|
146
|
+
closeSynchronously(o.close, copy);
|
|
147
|
+
removeAll(copy);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return copy;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A path for a database this test will BUILD, rather than copy — cleared and temporary.
|
|
156
|
+
*
|
|
157
|
+
* The same pid-keyed name, and therefore the same trap by a shorter route: a test that runs
|
|
158
|
+
* migrations onto a fresh file will happily do so beside a `-wal` left by an earlier run with this
|
|
159
|
+
* pid, and SQLite replays it into the new database. The main file being absent is no protection at
|
|
160
|
+
* all — it is the SIDECAR that carries the old rows.
|
|
161
|
+
*
|
|
162
|
+
* Returns the path. Nothing is created here; the caller's driver does that.
|
|
163
|
+
*/
|
|
164
|
+
function freshDatabase(o = {}) {
|
|
165
|
+
const label = String(o.name || 'test').replace(/[^a-z0-9-]+/gi, '-');
|
|
166
|
+
const file = path.join(o.dir || os.tmpdir(),
|
|
167
|
+
`${o.prefix || 'app'}-${label}-${process.pid}.db`);
|
|
168
|
+
removeAll(file);
|
|
169
|
+
if (o.cleanup !== false) {
|
|
170
|
+
process.on('exit', () => {
|
|
171
|
+
closeSynchronously(o.close, file);
|
|
172
|
+
removeAll(file);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return file;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Prove that what the app opened is the copy and NOT the live database.
|
|
180
|
+
*
|
|
181
|
+
* Worth its own function because the consequence of getting it wrong is silent and permanent: a
|
|
182
|
+
* suite that mutates live data looks exactly like a suite that passes.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} openFile what the driver reports it has open (PRAGMA database_list)
|
|
185
|
+
*/
|
|
186
|
+
function assertIsCopy(openFile, copyPath, livePath) {
|
|
187
|
+
const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
188
|
+
if (!openFile) throw new Error('assertIsCopy: the driver did not say which file it opened');
|
|
189
|
+
if (livePath && same(openFile, livePath)) {
|
|
190
|
+
throw new Error(`THIS IS THE LIVE DATABASE (${openFile}) — the test would mutate real data`);
|
|
191
|
+
}
|
|
192
|
+
if (!same(openFile, copyPath)) {
|
|
193
|
+
throw new Error(`opened ${openFile}, expected the copy at ${copyPath}`);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Sweep leftovers from earlier runs — for a housekeeping script, never for a test. */
|
|
199
|
+
function sweep(o = {}) {
|
|
200
|
+
const dir = o.dir || os.tmpdir();
|
|
201
|
+
const prefix = o.prefix || 'app';
|
|
202
|
+
let removed = 0;
|
|
203
|
+
let names;
|
|
204
|
+
try { names = fs.readdirSync(dir); } catch (e) { return { removed: 0 }; }
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
if (!name.startsWith(prefix + '-') || !/\.db(-wal|-shm)?$/.test(name)) continue;
|
|
207
|
+
// NEVER THIS RUN'S OWN COPY. A sweep that deletes the file the caller is using would turn a
|
|
208
|
+
// housekeeping convenience into the very corruption it exists to prevent.
|
|
209
|
+
if (name.includes('-' + process.pid + '.db')) continue;
|
|
210
|
+
try { fs.unlinkSync(path.join(dir, name)); removed += 1; } catch (e) { /* held open */ }
|
|
211
|
+
}
|
|
212
|
+
return { removed };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { copyDatabase, freshDatabase, assertIsCopy, sweep, _suffixes: SUFFIXES };
|