@carecard/auth-util 3.12.0 → 3.13.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/.agents/skills/carecard-workspace-standards/SKILL.md +6 -0
- package/.agents/skills/pkg-auth-util-auth-crypto-library/SKILL.md +20 -0
- package/.agents/skills/software-design-patterns-and-clean-code/SKILL.md +2 -0
- package/index.d.ts +8 -8
- package/package.json +9 -7
- package/readme.md +22 -0
- package/scripts/testOrder/randomizeTestOrder.cjs +40 -0
- package/scripts/testOrder/randomizeTestOrder.test.mjs +36 -0
- package/scripts/testOrder/testOrderPolicy.test.mjs +48 -0
- package/scripts/testParallel/parallelTestPolicy.test.mjs +39 -0
- package/scripts/testParallel/runIndexedMochaTests.cjs +71 -0
- package/scripts/testParallel/runIndexedMochaTests.test.mjs +21 -0
|
@@ -7,6 +7,10 @@ Non-negotiable root-cause solution rule: Always identify and solve the verified
|
|
|
7
7
|
|
|
8
8
|
# CareCard Workspace Standards
|
|
9
9
|
|
|
10
|
+
Non-negotiable test order invariance rule: Every test must pass independently of which tests run before or after it, and the suite must pass in every execution order. Each test must establish the state it needs, isolate mutable state, and clean up state it owns; it must never rely on another test's setup, mutations, or cleanup. Default test, CI, and Husky commands must use the test framework's ordinary ordering and must not force randomized ordering. Random-order execution is an explicit diagnostic only, and every failure it exposes must be fixed at the root cause.
|
|
11
|
+
|
|
12
|
+
Non-negotiable parallel test execution rule: Run independent test files in parallel with repository-native worker support wherever resource isolation makes parallel execution safe. Tests that share a mutable database, application server, browser state, filesystem fixture, port, or cluster resource must remain in an explicitly isolated serial group until every worker owns a separate resource. Parallel execution must preserve ordinary test selection and must never use randomized ordering, retries, locks, or error suppression to conceal coupling.
|
|
13
|
+
|
|
10
14
|
Non-negotiable TDD rule: Always write the failing test first, run it to confirm it fails for the intended reason, then implement the code and rerun the test until it passes. Test Driven Development is required for all coding work and must not be skipped. For documentation- or skill-only edits, add or update the relevant validation check before changing the prose.
|
|
11
15
|
|
|
12
16
|
This requirement is non-negotiable and may be overridden only with the user's
|
|
@@ -26,6 +30,8 @@ Non-negotiable repository isolation rule: Every repository must run its Husky ho
|
|
|
26
30
|
|
|
27
31
|
Non-negotiable error and warning rule: Never suppress, silence, hide, downgrade, filter, ignore, skip, or bypass errors or warnings from code, tests, tools, compilers, linters, or validation. Fix the root cause, then rerun the affected check and require a clean result. Expected error-path tests may assert errors, but must not conceal unexpected failures.
|
|
28
32
|
|
|
33
|
+
Non-negotiable TypeScript type rule: Never use the TypeScript type `any`; always use specific domain types, generics, existing project types, or `unknown` with explicit narrowing in all TypeScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, and `.d.ts`).
|
|
34
|
+
|
|
29
35
|
Non-negotiable code organization rule: Functions with the same or equivalent behavior must use the same or clearly corresponding descriptive names across CareCard repositories, and equivalent functionality must live in files with the same names within each repository's established architecture. No backward compatibility names, aliases, or duplicate locations are allowed.
|
|
30
36
|
|
|
31
37
|
## Purpose
|
|
@@ -13,6 +13,8 @@ Non-negotiable repository isolation rule: Every repository must run its Husky ho
|
|
|
13
13
|
|
|
14
14
|
Non-negotiable error and warning rule: Never suppress, silence, hide, downgrade, filter, ignore, skip, or bypass errors or warnings from code, tests, tools, compilers, linters, or validation. Fix the root cause, then rerun the affected check and require a clean result. Expected error-path tests may assert errors, but must not conceal unexpected failures.
|
|
15
15
|
|
|
16
|
+
Non-negotiable TypeScript type rule: Never use the TypeScript type `any`; always use specific domain types, generics, existing project types, or `unknown` with explicit narrowing in all TypeScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, and `.d.ts`).
|
|
17
|
+
|
|
16
18
|
Non-negotiable code organization rule: Functions with the same or equivalent behavior must use the same or clearly corresponding descriptive names across CareCard repositories, and equivalent functionality must live in files with the same names within each repository's established architecture. No backward compatibility names, aliases, or duplicate locations are allowed.
|
|
17
19
|
|
|
18
20
|
## Purpose
|
|
@@ -247,3 +249,21 @@ repository's agents-only Git workflow:
|
|
|
247
249
|
Do not commit or push `.agents` guidance changes directly from `development`
|
|
248
250
|
or `main`. Do not stage unrelated files, generated output, dependency folders,
|
|
249
251
|
build artifacts, logs, or `.DS_Store`.
|
|
252
|
+
|
|
253
|
+
## Fail-Closed Test Lifecycle Audit
|
|
254
|
+
|
|
255
|
+
The current package tests own no HTTP listener, database pool, Kafka client,
|
|
256
|
+
background timer, or child process after completion. Mocha's test timeout fails
|
|
257
|
+
a stalled async test, the suites run without bail or forced exit, and npm
|
|
258
|
+
preserves each command's nonzero status. Keep natural process exit as the open
|
|
259
|
+
handle regression check; validation must not hide failures with retries, forced
|
|
260
|
+
success, skipped tests, or output suppression.
|
|
261
|
+
|
|
262
|
+
Do not add unpublished executable validation code to a `pkg-*` repository. If a
|
|
263
|
+
future test owns a long-lived resource or demonstrates a post-suite hang, add a
|
|
264
|
+
contract-tested process watchdog through the coordinated package version,
|
|
265
|
+
publish, and consumer propagation workflow. That watchdog must return
|
|
266
|
+
immediately when no helper remains, allow only a bounded 250 ms settlement
|
|
267
|
+
window for already-stopping helpers, fail persistent descendants, preserve
|
|
268
|
+
failures and output, use exit code `124` only for a real outer deadline, and
|
|
269
|
+
remain a final guard rather than a substitute for explicit cleanup.
|
|
@@ -13,6 +13,8 @@ Non-negotiable repository isolation rule: Every repository must run its Husky ho
|
|
|
13
13
|
|
|
14
14
|
Non-negotiable error and warning rule: Never suppress, silence, hide, downgrade, filter, ignore, skip, or bypass errors or warnings from code, tests, tools, compilers, linters, or validation. Fix the root cause, then rerun the affected check and require a clean result. Expected error-path tests may assert errors, but must not conceal unexpected failures.
|
|
15
15
|
|
|
16
|
+
Non-negotiable TypeScript type rule: Never use the TypeScript type `any`; always use specific domain types, generics, existing project types, or `unknown` with explicit narrowing in all TypeScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, and `.d.ts`).
|
|
17
|
+
|
|
16
18
|
Non-negotiable code organization rule: Functions with the same or equivalent behavior must use the same or clearly corresponding descriptive names across CareCard repositories, and equivalent functionality must live in files with the same names within each repository's established architecture. No backward compatibility names, aliases, or duplicate locations are allowed.
|
|
17
19
|
|
|
18
20
|
## Purpose
|
package/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Utility functions for authentication and authorization in the CareCard ecosystem.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { Request } from 'express';
|
|
5
|
+
import type { Request } from 'express';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Represents the standard JWT header structure.
|
|
@@ -14,7 +14,7 @@ export interface JwtHeader {
|
|
|
14
14
|
typ?: string;
|
|
15
15
|
|
|
16
16
|
/** Any other custom header fields. */
|
|
17
|
-
[key: string]:
|
|
17
|
+
[key: string]: unknown;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -35,7 +35,7 @@ export interface JwtPayload {
|
|
|
35
35
|
roles?: string[];
|
|
36
36
|
|
|
37
37
|
/** Any other custom payload fields. */
|
|
38
|
-
[key: string]:
|
|
38
|
+
[key: string]: unknown;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -89,10 +89,10 @@ export interface JwtRequestObject {
|
|
|
89
89
|
header: JwtHeader;
|
|
90
90
|
payload: JwtPayload;
|
|
91
91
|
age?: number;
|
|
92
|
-
jwtClientId: (req?:
|
|
92
|
+
jwtClientId: (req?: AuthenticatedRequest) => string | undefined;
|
|
93
93
|
doesJwtUserHasRole: (role: string) => boolean;
|
|
94
94
|
isJwtExpired: (jwtValiditySeconds?: number) => boolean;
|
|
95
|
-
jwtAgeInSeconds: (req?:
|
|
95
|
+
jwtAgeInSeconds: (req?: AuthenticatedRequest) => number;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/**
|
|
@@ -101,7 +101,7 @@ export interface JwtRequestObject {
|
|
|
101
101
|
export interface VisitorRequestObject {
|
|
102
102
|
header: JwtHeader;
|
|
103
103
|
payload: JwtPayload;
|
|
104
|
-
visitorClientId: (req?:
|
|
104
|
+
visitorClientId: (req?: AuthenticatedRequest) => string | undefined;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
@@ -248,14 +248,14 @@ export const stringUtilAuth: {
|
|
|
248
248
|
* @param object - The object to serialize.
|
|
249
249
|
* @returns URL-safe base64 string.
|
|
250
250
|
*/
|
|
251
|
-
objectToBase64UrlSafeString: (object:
|
|
251
|
+
objectToBase64UrlSafeString: (object: unknown) => string;
|
|
252
252
|
/**
|
|
253
253
|
* Parses a URL-safe base64 string into an object.
|
|
254
254
|
* @deprecated Use native Buffer methods or other modern alternatives.
|
|
255
255
|
* @param urlSafeBase64String - URL-safe base64 string.
|
|
256
256
|
* @returns The parsed object.
|
|
257
257
|
*/
|
|
258
|
-
urlSafeBase64ToObject: (urlSafeBase64String: string) =>
|
|
258
|
+
urlSafeBase64ToObject: (urlSafeBase64String: string) => unknown;
|
|
259
259
|
};
|
|
260
260
|
|
|
261
261
|
/**
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carecard/auth-util",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"repository": "https://github.com/CareCard-ca/pkg-auth-util.git",
|
|
5
5
|
"description": "Auth utility functions",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "
|
|
10
|
-
"test:
|
|
11
|
-
"test:
|
|
9
|
+
"test": "npm run test:order && node test/index.test.js",
|
|
10
|
+
"test:order": "node --test scripts/testOrder/randomizeTestOrder.test.mjs scripts/testOrder/testOrderPolicy.test.mjs scripts/testParallel/runIndexedMochaTests.test.mjs scripts/testParallel/parallelTestPolicy.test.mjs",
|
|
11
|
+
"test:types": "npm run test:order && tsc --noEmit && mocha --require ./scripts/testOrder/randomizeTestOrder.cjs -r ts-node/register test/types.test.ts",
|
|
12
|
+
"test:coverage": "npm run test:order && tsc --noEmit && nyc node test/index.test.js",
|
|
12
13
|
"test:All": "npm run test && npm run test:types",
|
|
13
14
|
"format": "prettier --write .",
|
|
14
15
|
"format:check": "prettier --check .",
|
|
@@ -41,14 +42,15 @@
|
|
|
41
42
|
"typescript": "6.0.3"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
|
-
"@carecard/common-util": "3.
|
|
45
|
-
"@carecard/validate": "3.
|
|
45
|
+
"@carecard/common-util": "3.13.0",
|
|
46
|
+
"@carecard/validate": "3.13.0",
|
|
46
47
|
"@types/express": "5.0.6"
|
|
47
48
|
},
|
|
48
49
|
"overrides": {
|
|
49
50
|
"diff": "8.0.4",
|
|
50
51
|
"glob": "13.0.6",
|
|
52
|
+
"minimatch": "10.2.5",
|
|
51
53
|
"serialize-javascript": "7.0.5",
|
|
52
|
-
"js-yaml": "4.
|
|
54
|
+
"js-yaml": "4.3.0"
|
|
53
55
|
}
|
|
54
56
|
}
|
package/readme.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @carecard/auth-util
|
|
2
2
|
|
|
3
|
+
Non-negotiable test order invariance rule: Every test must pass independently of which tests run before or after it, and the suite must pass in every execution order. Each test must establish the state it needs, isolate mutable state, and clean up state it owns; it must never rely on another test's setup, mutations, or cleanup. Default test, CI, and Husky commands must use the test framework's ordinary ordering and must not force randomized ordering. Random-order execution is an explicit diagnostic only, and every failure it exposes must be fixed at the root cause.
|
|
4
|
+
|
|
3
5
|
Non-negotiable root-cause solution rule: Always identify and solve the verified root cause, use the stronger solution, and deliver a correct, durable, production-quality result. Never treat a temporary workaround, resource increase, retry, suppression, bypass, or symptom-only patch as completion. Validate the root-cause fix against the real failing workflow and prove the end state.
|
|
4
6
|
|
|
5
7
|

|
|
@@ -15,6 +17,8 @@ Non-negotiable repository isolation rule: Every repository must run its Husky ho
|
|
|
15
17
|
|
|
16
18
|
Non-negotiable error and warning rule: Never suppress, silence, hide, downgrade, filter, ignore, skip, or bypass errors or warnings from code, tests, tools, compilers, linters, or validation. Fix the root cause, then rerun the affected check and require a clean result. Expected error-path tests may assert errors, but must not conceal unexpected failures.
|
|
17
19
|
|
|
20
|
+
Non-negotiable TypeScript type rule: Never use the TypeScript type `any`; always use specific domain types, generics, existing project types, or `unknown` with explicit narrowing in all TypeScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, and `.d.ts`).
|
|
21
|
+
|
|
18
22
|
Non-negotiable code organization rule: Functions with the same or equivalent behavior must use the same or clearly corresponding descriptive names across CareCard repositories, and equivalent functionality must live in files with the same names within each repository's established architecture. No backward compatibility names, aliases, or duplicate locations are allowed.
|
|
19
23
|
|
|
20
24
|
## Features
|
|
@@ -150,3 +154,21 @@ The package is organized into several modules:
|
|
|
150
154
|
- `stringUtilAuth`: String manipulation and format conversions.
|
|
151
155
|
|
|
152
156
|
All modules are exported through the main `index.js`.
|
|
157
|
+
|
|
158
|
+
## Fail-Closed Test Lifecycle Audit
|
|
159
|
+
|
|
160
|
+
The current package tests own no HTTP listener, database pool, Kafka client,
|
|
161
|
+
background timer, or child process after completion. Mocha's test timeout fails
|
|
162
|
+
a stalled async test, the suites run without bail or forced exit, and npm
|
|
163
|
+
preserves each command's nonzero status. Keep natural process exit as the open
|
|
164
|
+
handle regression check; validation must not hide failures with retries, forced
|
|
165
|
+
success, skipped tests, or output suppression.
|
|
166
|
+
|
|
167
|
+
Do not add unpublished executable validation code to a `pkg-*` repository. If a
|
|
168
|
+
future test owns a long-lived resource or demonstrates a post-suite hang, add a
|
|
169
|
+
contract-tested process watchdog through the coordinated package version,
|
|
170
|
+
publish, and consumer propagation workflow. That watchdog must return
|
|
171
|
+
immediately when no helper remains, allow only a bounded 250 ms settlement
|
|
172
|
+
window for already-stopping helpers, fail persistent descendants, preserve
|
|
173
|
+
failures and output, use exit code `124` only for a real outer deadline, and
|
|
174
|
+
remain a final guard rather than a substitute for explicit cleanup.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MAX_TEST_ORDER_SEED = 2_147_483_647;
|
|
4
|
+
|
|
5
|
+
function resolveTestOrderSeed(configuredSeed) {
|
|
6
|
+
if (configuredSeed === undefined) return undefined;
|
|
7
|
+
if (!/^[1-9]\d*$/.test(configuredSeed)) throw new Error('TEST_ORDER_SEED must be a positive 32-bit integer.');
|
|
8
|
+
const seed = Number(configuredSeed);
|
|
9
|
+
if (!Number.isSafeInteger(seed) || seed > MAX_TEST_ORDER_SEED) throw new Error('TEST_ORDER_SEED must be a positive 32-bit integer.');
|
|
10
|
+
return seed;
|
|
11
|
+
}
|
|
12
|
+
function createSeededRandom(seed) {
|
|
13
|
+
let state = seed;
|
|
14
|
+
return function nextRandomValue() {
|
|
15
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
16
|
+
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
17
|
+
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
18
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function shuffleValues(values, random) {
|
|
22
|
+
for (let index = values.length - 1; index > 0; index -= 1) {
|
|
23
|
+
const replacementIndex = Math.floor(random() * (index + 1));
|
|
24
|
+
[values[index], values[replacementIndex]] = [values[replacementIndex], values[index]];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function shuffleSuiteTree(suite, random) {
|
|
28
|
+
for (const childSuite of suite.suites) shuffleSuiteTree(childSuite, random);
|
|
29
|
+
shuffleValues(suite.tests, random);
|
|
30
|
+
shuffleValues(suite.suites, random);
|
|
31
|
+
}
|
|
32
|
+
const mochaHooks = {
|
|
33
|
+
beforeAll() {
|
|
34
|
+
const seed = resolveTestOrderSeed(process.env.TEST_ORDER_SEED);
|
|
35
|
+
if (seed === undefined) return;
|
|
36
|
+
console.log(`Test order seed: ${seed} (reproduce with TEST_ORDER_SEED=${seed})`);
|
|
37
|
+
shuffleSuiteTree(this.test.parent, createSeededRandom(seed));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
module.exports = { createSeededRandom, mochaHooks, resolveTestOrderSeed, shuffleSuiteTree };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import testOrderRandomizer from './randomizeTestOrder.cjs';
|
|
5
|
+
|
|
6
|
+
const { createSeededRandom, resolveTestOrderSeed, shuffleSuiteTree } = testOrderRandomizer;
|
|
7
|
+
|
|
8
|
+
function createSuiteTree() {
|
|
9
|
+
return {
|
|
10
|
+
suites: [
|
|
11
|
+
{ title: 'alpha', suites: [], tests: [{ title: 'one' }, { title: 'two' }] },
|
|
12
|
+
{ title: 'beta', suites: [], tests: [{ title: 'three' }, { title: 'four' }] },
|
|
13
|
+
{ title: 'gamma', suites: [], tests: [{ title: 'five' }, { title: 'six' }] },
|
|
14
|
+
],
|
|
15
|
+
tests: [{ title: 'root one' }, { title: 'root two' }, { title: 'root three' }],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('uses ordinary ordering unless TEST_ORDER_SEED is explicitly supplied', () => {
|
|
20
|
+
assert.strictEqual(resolveTestOrderSeed(undefined), undefined);
|
|
21
|
+
assert.strictEqual(resolveTestOrderSeed('314159'), 314159);
|
|
22
|
+
for (const invalidSeed of ['', '0', '-1', '1.5', 'seed', '2147483648']) {
|
|
23
|
+
assert.throws(() => resolveTestOrderSeed(invalidSeed), /TEST_ORDER_SEED/);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('shuffles nested suites and tests reproducibly', () => {
|
|
28
|
+
const firstTree = createSuiteTree();
|
|
29
|
+
const secondTree = createSuiteTree();
|
|
30
|
+
|
|
31
|
+
shuffleSuiteTree(firstTree, createSeededRandom(314159));
|
|
32
|
+
shuffleSuiteTree(secondTree, createSeededRandom(314159));
|
|
33
|
+
|
|
34
|
+
assert.deepStrictEqual(firstTree, secondTree);
|
|
35
|
+
assert.notDeepStrictEqual(firstTree, createSuiteTree());
|
|
36
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
|
|
6
|
+
const TEST_ORDER_INVARIANCE_RULE =
|
|
7
|
+
"Non-negotiable test order invariance rule: Every test must pass independently of which tests run before or after it, and the suite must pass in every execution order. Each test must establish the state it needs, isolate mutable state, and clean up state it owns; it must never rely on another test's setup, mutations, or cleanup. Default test, CI, and Husky commands must use the test framework's ordinary ordering and must not force randomized ordering. Random-order execution is an explicit diagnostic only, and every failure it exposes must be fixed at the root cause.";
|
|
8
|
+
|
|
9
|
+
function listRepositoryFiles() {
|
|
10
|
+
return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8' })
|
|
11
|
+
.trim()
|
|
12
|
+
.split('\n')
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRequiredTestGuidance(filePath) {
|
|
17
|
+
return (
|
|
18
|
+
/^readme\.md$/i.test(filePath) ||
|
|
19
|
+
filePath === '.codex/AGENTS.md' ||
|
|
20
|
+
filePath === '.junie/guidelines.md' ||
|
|
21
|
+
filePath === '.agents/skills/carecard-workspace-standards/SKILL.md' ||
|
|
22
|
+
/^\.agents\/skills\/[^/]*(?:test|testing)[^/]*\/(?:SKILL\.md|references\/[^/]*(?:test|testing|coding-principles)[^/]*\.md)$/i.test(
|
|
23
|
+
filePath,
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('keeps the non-negotiable test order rule in repository guidance', () => {
|
|
29
|
+
const guidanceFiles = listRepositoryFiles().filter(isRequiredTestGuidance);
|
|
30
|
+
assert.ok(guidanceFiles.length > 0, 'No repository test guidance was found.');
|
|
31
|
+
|
|
32
|
+
for (const guidanceFile of guidanceFiles) {
|
|
33
|
+
const normalizedGuidance = readFileSync(guidanceFile, 'utf8').replace(/\s+/g, ' ').trim();
|
|
34
|
+
assert.ok(
|
|
35
|
+
normalizedGuidance.includes(TEST_ORDER_INVARIANCE_RULE),
|
|
36
|
+
`${guidanceFile} must document the non-negotiable test order invariance rule.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('keeps default package scripts on the test framework ordinary ordering', () => {
|
|
42
|
+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
43
|
+
|
|
44
|
+
for (const [scriptName, command] of Object.entries(packageJson.scripts ?? {})) {
|
|
45
|
+
assert.equal(typeof command, 'string', `${scriptName} must be a string command.`);
|
|
46
|
+
assert.doesNotMatch(command, /--test-randomize|--test-random-seed/, `${scriptName} must not force randomized test ordering.`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const repositoryRoot = resolve(import.meta.dirname, '../..');
|
|
9
|
+
const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
const testIndexSource = readFileSync(new URL('../../test/index.test.js', import.meta.url), 'utf8');
|
|
11
|
+
const { parallelTestFiles } = require('../../test/index.test.js');
|
|
12
|
+
|
|
13
|
+
function listRuntimeTestFiles(directoryPath) {
|
|
14
|
+
return readdirSync(directoryPath, { withFileTypes: true }).flatMap(entry => {
|
|
15
|
+
const entryPath = join(directoryPath, entry.name);
|
|
16
|
+
if (entry.isDirectory()) return listRuntimeTestFiles(entryPath);
|
|
17
|
+
if (!/\.test\.(?:js|mjs)$/.test(entry.name) || entry.name === 'index.test.js') {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
return [relative(repositoryRoot, entryPath)];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('keeps runtime test selection in the index and package scripts short', () => {
|
|
25
|
+
assert.equal(packageJson.scripts.test, 'npm run test:order && node test/index.test.js');
|
|
26
|
+
assert.match(packageJson.scripts['test:coverage'], /nyc node test\/index\.test\.js$/);
|
|
27
|
+
assert.match(testIndexSource, /parallelTestFiles/);
|
|
28
|
+
assert.match(testIndexSource, /runIndexedMochaTests/);
|
|
29
|
+
assert.match(testIndexSource, /if \(require\.main === module\)/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('runs the parallel execution contract in the test-order gate', () => {
|
|
33
|
+
assert.match(packageJson.scripts['test:order'], /scripts\/testParallel\/parallelTestPolicy\.test\.mjs/);
|
|
34
|
+
assert.match(packageJson.scripts['test:order'], /scripts\/testParallel\/runIndexedMochaTests\.test\.mjs/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('selects every runtime test file exactly once', () => {
|
|
38
|
+
assert.deepEqual([...parallelTestFiles].sort(), listRuntimeTestFiles(resolve(repositoryRoot, 'test')).sort());
|
|
39
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { createRequire } = require('node:module');
|
|
5
|
+
const { availableParallelism } = require('node:os');
|
|
6
|
+
const { resolve } = require('node:path');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_PARALLEL_JOBS = 4;
|
|
9
|
+
|
|
10
|
+
// Pattern: Configuration Boundary - bounds workers without accepting invalid input.
|
|
11
|
+
function resolveParallelJobCount(
|
|
12
|
+
configuredJobCount,
|
|
13
|
+
testFileCount,
|
|
14
|
+
defaultMaximum = DEFAULT_MAX_PARALLEL_JOBS,
|
|
15
|
+
availableJobCount = availableParallelism(),
|
|
16
|
+
) {
|
|
17
|
+
const requestedJobCount =
|
|
18
|
+
configuredJobCount === undefined ? Math.min(availableJobCount, defaultMaximum) : Number.parseInt(configuredJobCount, 10);
|
|
19
|
+
|
|
20
|
+
if (!Number.isInteger(requestedJobCount) || requestedJobCount < 1) {
|
|
21
|
+
throw new Error('TEST_PARALLEL_JOBS must be a positive integer.');
|
|
22
|
+
}
|
|
23
|
+
return Math.min(requestedJobCount, testFileCount);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pattern: Command Builder - keeps Mocha worker details out of package metadata.
|
|
27
|
+
function buildMochaArguments(testFiles, jobCount) {
|
|
28
|
+
const requireFromRunner = createRequire(__filename);
|
|
29
|
+
return [
|
|
30
|
+
requireFromRunner.resolve('mocha/bin/mocha.js'),
|
|
31
|
+
'--parallel',
|
|
32
|
+
'--jobs',
|
|
33
|
+
String(jobCount),
|
|
34
|
+
'--require',
|
|
35
|
+
resolve('scripts/testOrder/randomizeTestOrder.cjs'),
|
|
36
|
+
...testFiles,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pattern: Process Adapter - returns the exact test process result to the index.
|
|
41
|
+
function runIndexedMochaTests(testFiles) {
|
|
42
|
+
if (testFiles.length === 0) {
|
|
43
|
+
throw new Error('The package test index must select at least one test file.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const jobCount = resolveParallelJobCount(process.env.TEST_PARALLEL_JOBS, testFiles.length);
|
|
47
|
+
const child = spawn(process.execPath, buildMochaArguments(testFiles, jobCount), {
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
NODE_ENV: 'test',
|
|
51
|
+
},
|
|
52
|
+
stdio: 'inherit',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return new Promise((resolveExit, rejectExit) => {
|
|
56
|
+
child.once('error', rejectExit);
|
|
57
|
+
child.once('exit', (code, signal) => {
|
|
58
|
+
if (signal) {
|
|
59
|
+
rejectExit(new Error(`Mocha exited from signal ${signal}.`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
resolveExit(code ?? 1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
buildMochaArguments,
|
|
69
|
+
resolveParallelJobCount,
|
|
70
|
+
runIndexedMochaTests,
|
|
71
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { buildMochaArguments, resolveParallelJobCount } = require('./runIndexedMochaTests.cjs');
|
|
7
|
+
|
|
8
|
+
test('uses bounded Mocha file workers without randomized default ordering', () => {
|
|
9
|
+
assert.equal(resolveParallelJobCount(undefined, 8, 4, 12), 4);
|
|
10
|
+
assert.equal(resolveParallelJobCount('2', 8, 4, 12), 2);
|
|
11
|
+
|
|
12
|
+
const argumentsList = buildMochaArguments(['test/example.test.js'], 2);
|
|
13
|
+
|
|
14
|
+
assert.ok(argumentsList.includes('--parallel'));
|
|
15
|
+
assert.deepEqual(argumentsList.slice(argumentsList.indexOf('--jobs'), argumentsList.indexOf('--jobs') + 2), ['--jobs', '2']);
|
|
16
|
+
assert.ok(argumentsList.includes('test/example.test.js'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('rejects invalid worker configuration instead of changing execution silently', () => {
|
|
20
|
+
assert.throws(() => resolveParallelJobCount('0', 8, 4, 12), /TEST_PARALLEL_JOBS must be a positive integer/);
|
|
21
|
+
});
|