@aria-framework/testkit 0.1.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/harness.js +79 -0
- package/importsInScope.js +98 -0
- package/index.js +21 -0
- package/package.json +20 -0
- package/versionSync.js +59 -0
package/harness.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createHarness — the ✓/✗ micro test-harness that existed as ~111 hand-rolled copies across the
|
|
3
|
+
* two apps and this monorepo's own package tests, drifted into three incompatible variants.
|
|
4
|
+
*
|
|
5
|
+
* The variant differences were not cosmetic. The one that matters: some copies' `check()` accepted
|
|
6
|
+
* an async callback and returned before its assertions ran — a WHOLE SUITE silently passing. This
|
|
7
|
+
* harness makes that structural: `check()` REFUSES a callback that returns a thenable (use
|
|
8
|
+
* `acheck`), so the failure mode is a loud error at the call site instead of a green lie.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately tiny and dependency-free — same discipline as the copies it replaces, minus the
|
|
11
|
+
* copy-paste. `done()` prints the summary and sets the exit code; keeping process.exit behind it
|
|
12
|
+
* (instead of sprinkled at file bottoms) lets a suite run inside a larger runner.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{log?: (line: string) => void, truncate?: number}} [opts]
|
|
19
|
+
* truncate: cap failure detail lines (0 = no cap). Some old copies cut to one line and hid the
|
|
20
|
+
* assertion diff; the default keeps everything.
|
|
21
|
+
*/
|
|
22
|
+
function createHarness(opts = {}) {
|
|
23
|
+
const log = opts.log || ((line) => console.log(line));
|
|
24
|
+
const cap = opts.truncate || 0;
|
|
25
|
+
let pass = 0;
|
|
26
|
+
let fail = 0;
|
|
27
|
+
|
|
28
|
+
const detail = (e) => {
|
|
29
|
+
let msg = e && (e.message || String(e));
|
|
30
|
+
if (cap > 0) msg = String(msg).split('\n').slice(0, cap).join('\n');
|
|
31
|
+
return msg;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ok = (name) => { pass++; log(' ✓ ' + name); };
|
|
35
|
+
const bad = (name, e) => { fail++; log(' ✗ ' + name + (e ? '\n ' + detail(e) : '')); };
|
|
36
|
+
|
|
37
|
+
/** Sync check. An async callback is a BUG here — its assertions would be ignored. */
|
|
38
|
+
const check = (name, fn) => {
|
|
39
|
+
try {
|
|
40
|
+
const r = fn();
|
|
41
|
+
if (r && typeof r.then === 'function') {
|
|
42
|
+
// DEFUSE the orphaned promise before failing the check: its body may already have
|
|
43
|
+
// rejected (an assertion inside an async callback), and an unhandled rejection would
|
|
44
|
+
// crash the whole suite instead of failing this one check.
|
|
45
|
+
r.catch(() => {});
|
|
46
|
+
throw new Error('async callback passed to check() — its assertions would be ignored; use acheck()');
|
|
47
|
+
}
|
|
48
|
+
ok(name);
|
|
49
|
+
} catch (e) { bad(name, e); }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Async-aware check. Await it (or run inside an async IIFE the way the suites already do). */
|
|
53
|
+
const acheck = async (name, fn) => {
|
|
54
|
+
try { await fn(); ok(name); } catch (e) { bad(name, e); }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Print the summary; set the process exit code. Returns the failure count so a caller composing
|
|
59
|
+
* suites can decide for itself.
|
|
60
|
+
* @param {{exit?: boolean}} [o] exit:true (default) calls process.exit — the standalone-script
|
|
61
|
+
* behaviour every existing suite has; pass false inside a larger runner.
|
|
62
|
+
*/
|
|
63
|
+
const done = (o = {}) => {
|
|
64
|
+
log(`\n${pass} passed, ${fail} failed`);
|
|
65
|
+
if (o.exit === false) {
|
|
66
|
+
process.exitCode = fail ? 1 : process.exitCode;
|
|
67
|
+
return fail;
|
|
68
|
+
}
|
|
69
|
+
process.exit(fail ? 1 : 0);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ok, bad, check, acheck, done,
|
|
74
|
+
get passed() { return pass; },
|
|
75
|
+
get failed() { return fail; }
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { createHarness };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertImportsInScope — every consumer of a module has every function it CALLS in scope.
|
|
3
|
+
*
|
|
4
|
+
* The failure this guards is silent by construction: a name missing from a destructure is a
|
|
5
|
+
* ReferenceError at runtime — and when the call sits inside a catch that returns [], it becomes an
|
|
6
|
+
* empty list that reads exactly like having no data. It shipped that way once (a silently-empty
|
|
7
|
+
* backup list), which is why this exists. The two apps carried this file byte-identically apart
|
|
8
|
+
* from the header prose; the module path and matcher are now parameters.
|
|
9
|
+
*
|
|
10
|
+
* HONEST LIMITS (read before pointing it at something new): the scope scan is a REGEX HEURISTIC
|
|
11
|
+
* over CommonJS — destructures, const/let/var declarations, function declarations. It does not
|
|
12
|
+
* parse; ESM `import` is not understood and produces false negatives, and an exotic declaration
|
|
13
|
+
* style (deep nesting inside eval'd strings, computed keys) is invisible to it. It is deliberately
|
|
14
|
+
* over-permissive: it can miss a problem, it should not invent one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
/** Names a file has in scope: destructured anywhere, declared, or function-declared. */
|
|
23
|
+
function inScope(src) {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
for (const m of src.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
|
|
26
|
+
for (const part of m[1].split(',')) {
|
|
27
|
+
const n = part.split(':').pop().trim();
|
|
28
|
+
if (n) names.add(n);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) names.add(m[1]);
|
|
32
|
+
for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
33
|
+
return names;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{
|
|
38
|
+
* root: string, app root (absolute)
|
|
39
|
+
* modulePath: string, path (relative to root) of the module whose exports are the vocabulary
|
|
40
|
+
* requireMatcher: RegExp, a file is a CONSUMER when its source matches (e.g. /require\([^)]*backup\/core[^)]*\)/)
|
|
41
|
+
* dirs?: string[], flat-scanned for consumers; default ['routes','lib','scripts']
|
|
42
|
+
* minConsumers?: number, self-check floor (default 2) — zero consumers means the finder drifted
|
|
43
|
+
* minCallable?: number self-check floor (default 3) — a tiny export list means the wrong module
|
|
44
|
+
* }} opts
|
|
45
|
+
* @returns {{consumers: number, callable: number}} on success
|
|
46
|
+
* @throws Error naming every file + missing name
|
|
47
|
+
*/
|
|
48
|
+
function assertImportsInScope(opts = {}) {
|
|
49
|
+
const { root, modulePath, requireMatcher } = opts;
|
|
50
|
+
if (!root || !modulePath || !(requireMatcher instanceof RegExp)) {
|
|
51
|
+
throw new Error('assertImportsInScope({ root, modulePath, requireMatcher }): all three are required');
|
|
52
|
+
}
|
|
53
|
+
const dirs = opts.dirs || ['routes', 'lib', 'scripts'];
|
|
54
|
+
const minConsumers = opts.minConsumers == null ? 2 : opts.minConsumers;
|
|
55
|
+
const minCallable = opts.minCallable == null ? 3 : opts.minCallable;
|
|
56
|
+
|
|
57
|
+
const mod = require(path.join(root, modulePath));
|
|
58
|
+
const CALLABLE = new Set(Object.keys(mod).filter((k) => typeof mod[k] === 'function'));
|
|
59
|
+
|
|
60
|
+
const consumers = [];
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
const abs = path.join(root, dir);
|
|
63
|
+
if (!fs.existsSync(abs)) continue;
|
|
64
|
+
for (const name of fs.readdirSync(abs)) {
|
|
65
|
+
if (!name.endsWith('.js')) continue;
|
|
66
|
+
const src = fs.readFileSync(path.join(abs, name), 'utf8');
|
|
67
|
+
if (requireMatcher.test(src)) consumers.push({ rel: `${dir}/${name}`, src });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const problems = [];
|
|
72
|
+
if (consumers.length < minConsumers) {
|
|
73
|
+
problems.push(`only ${consumers.length} consumer(s) found — if the finder matches nothing, the check passes while checking nothing`);
|
|
74
|
+
}
|
|
75
|
+
if (CALLABLE.size < minCallable) {
|
|
76
|
+
problems.push(`the module exports only ${CALLABLE.size} function(s) — is modulePath pointing at the right file?`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const { rel, src } of consumers) {
|
|
80
|
+
const scope = inScope(src);
|
|
81
|
+
const missing = new Set();
|
|
82
|
+
// A call site: an identifier followed by "(", NOT preceded by a dot (`core.foo(` is a
|
|
83
|
+
// property access, not a bare reference) and not part of a longer identifier.
|
|
84
|
+
for (const m of src.matchAll(/(^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
85
|
+
const name = m[2];
|
|
86
|
+
if (CALLABLE.has(name) && !scope.has(name)) missing.add(name);
|
|
87
|
+
}
|
|
88
|
+
if (missing.size) {
|
|
89
|
+
problems.push(`${rel} calls ${[...missing].join(', ')} but never brings it into scope — a ` +
|
|
90
|
+
'ReferenceError at runtime, and inside a catch it reads as an empty result');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (problems.length) throw new Error('import-scope violations:\n - ' + problems.join('\n - '));
|
|
95
|
+
return { consumers: consumers.length, callable: CALLABLE.size };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { assertImportsInScope };
|
package/index.js
ADDED
|
@@ -0,0 +1,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
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
package/versionSync.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertVersionSingleSource — every stored copy of the app version agrees.
|
|
3
|
+
*
|
|
4
|
+
* The rule, and the history behind it: a second copy of the version that nothing validates WILL
|
|
5
|
+
* rot. Both apps hand-maintained config/version.js and drifted (27 minors in one, 9 commits in
|
|
6
|
+
* the other) — fixed by DERIVING it. Then the lockfile did the same thing: version bumps are
|
|
7
|
+
* applied by editing package.json, which only `npm install` syncs into package-lock.json, so one
|
|
8
|
+
* app's lock sat six releases behind the tree — in the file `npm ci` reads on every deploy.
|
|
9
|
+
*
|
|
10
|
+
* Deriving is not available for the lockfile (npm owns its format), so this is the next best
|
|
11
|
+
* thing: a check that fails the suite the moment the copies disagree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{
|
|
20
|
+
* root: string, app root (absolute)
|
|
21
|
+
* derivedFiles?: string[], modules exporting { version } that must equal package.json's
|
|
22
|
+
* (default ['config/version.js'])
|
|
23
|
+
* lockfile?: boolean also check package-lock.json root + packages[""] (default true)
|
|
24
|
+
* }} opts
|
|
25
|
+
* @returns {{version: string}} on success
|
|
26
|
+
* @throws Error naming every disagreeing copy
|
|
27
|
+
*/
|
|
28
|
+
function assertVersionSingleSource(opts = {}) {
|
|
29
|
+
const root = opts.root;
|
|
30
|
+
if (!root) throw new Error('assertVersionSingleSource({ root }): the app root is required');
|
|
31
|
+
const derived = opts.derivedFiles || [path.join('config', 'version.js')];
|
|
32
|
+
|
|
33
|
+
const pkg = require(path.join(root, 'package.json'));
|
|
34
|
+
const problems = [];
|
|
35
|
+
|
|
36
|
+
for (const rel of derived) {
|
|
37
|
+
const mod = require(path.join(root, rel));
|
|
38
|
+
if (mod.version !== pkg.version) {
|
|
39
|
+
problems.push(`${rel} says ${mod.version}, package.json says ${pkg.version} — it must DERIVE, not be maintained by hand`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (opts.lockfile !== false) {
|
|
44
|
+
const lock = require(path.join(root, 'package-lock.json'));
|
|
45
|
+
if (lock.version !== pkg.version) {
|
|
46
|
+
problems.push(`package-lock.json root says ${lock.version} — run \`npm install --package-lock-only\` after bumping`);
|
|
47
|
+
}
|
|
48
|
+
const rootPkg = lock.packages && lock.packages[''];
|
|
49
|
+
if (!rootPkg) problems.push('package-lock.json has no packages[""] entry — expected lockfile v3');
|
|
50
|
+
else if (rootPkg.version !== pkg.version) {
|
|
51
|
+
problems.push(`package-lock.json packages[""] says ${rootPkg.version} — the second copy inside the lockfile drifts independently`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (problems.length) throw new Error('version copies disagree:\n - ' + problems.join('\n - '));
|
|
56
|
+
return { version: pkg.version };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { assertVersionSingleSource };
|