@hublo/sentinel 1.4.0-alpha.9 → 1.4.1
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/README.md +1 -0
- package/dist/bin/sentinel.js +45 -101
- package/dist/{chunk-7PUVK4YM.js → chunk-5VNYQIFD.js} +224 -13
- package/dist/chunk-5VNYQIFD.js.map +1 -0
- package/dist/chunk-DYJ6J43A.js +6419 -0
- package/dist/chunk-ELZHIN6E.js +16 -0
- package/dist/chunk-ELZHIN6E.js.map +1 -0
- package/dist/{chunk-L7WS36XV.js → chunk-GRK2KRFI.js} +221 -4427
- package/dist/chunk-H3GSGAX2.js +11 -0
- package/dist/chunk-H3GSGAX2.js.map +1 -0
- package/dist/{chunk-WLFE5RUU.js → chunk-KMKQDGI6.js} +6 -8
- package/dist/chunk-KMKQDGI6.js.map +1 -0
- package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
- package/dist/chunk-MMKBDO5V.js.map +1 -0
- package/dist/chunk-NUOQXAYR.js +14 -0
- package/dist/chunk-NUOQXAYR.js.map +1 -0
- package/dist/chunk-O7REVMOC.js +38 -0
- package/dist/chunk-O7REVMOC.js.map +1 -0
- package/dist/chunk-QXFCZON7.js +16 -0
- package/dist/chunk-QXFCZON7.js.map +1 -0
- package/dist/{chunk-3TDUIKVQ.js → chunk-SWWQ7X7B.js} +11 -8
- package/dist/{chunk-3TDUIKVQ.js.map → chunk-SWWQ7X7B.js.map} +1 -1
- package/dist/{chunk-CPCUPK4J.js → chunk-Z7L4FGKP.js} +5 -12
- package/dist/chunk-Z7L4FGKP.js.map +1 -0
- package/dist/index.d.ts +28 -5
- package/dist/index.js +7 -3
- package/dist/roles/build/nest/toolchain.js +11 -33
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/test/nest/toolchain.js +3 -2
- package/dist/roles/test/nest/toolchain.js.map +1 -1
- package/dist/roles/test/react/toolchain.js +3 -2
- package/dist/roles/test/react/toolchain.js.map +1 -1
- package/dist/roles/test/setup/a11y.d.ts +45 -0
- package/dist/roles/test/setup/a11y.js +72 -0
- package/dist/roles/test/setup/a11y.js.map +1 -0
- package/dist/roles/test/setup/file-boundary-close.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary-close.js +8 -0
- package/dist/roles/test/setup/file-boundary-close.js.map +1 -0
- package/dist/roles/test/setup/file-boundary.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary.js +62 -0
- package/dist/roles/test/setup/file-boundary.js.map +1 -0
- package/dist/roles/test/setup/jest-parity.js +19 -2
- package/dist/roles/test/setup/jest-parity.js.map +1 -1
- package/dist/roles/test/setup/msw-lifecycle.js +2 -1
- package/dist/roles/test/setup/msw-lifecycle.js.map +1 -1
- package/dist/roles/test/setup/msw-server.js +2 -1
- package/dist/roles/test/setup/msw-server.js.map +1 -1
- package/dist/roles/test/setup/w3c.d.ts +75 -0
- package/dist/roles/test/setup/w3c.js +49 -0
- package/dist/roles/test/setup/w3c.js.map +1 -0
- package/dist/roles/test/setup/workspace-entry.js +3 -2
- package/dist/roles/test/setup/workspace-entry.js.map +1 -1
- package/dist/roles/test/shared-test-config.d.ts +13 -0
- package/dist/roles/test/shared-test-config.js +3 -2
- package/dist/validate-57H7GK5B.js +170 -0
- package/docs/test-adoption.md +282 -5
- package/docs/using-sentinel.md +17 -1
- package/docs/validating-a-change.md +35 -2
- package/package.json +25 -1
- package/types/jest-global.d.ts +85 -0
- package/types/mock-extended.d.ts +52 -0
- package/dist/chunk-7PUVK4YM.js.map +0 -1
- package/dist/chunk-CPCUPK4J.js.map +0 -1
- package/dist/chunk-PWV3BMDA.js.map +0 -1
- package/dist/chunk-WLFE5RUU.js.map +0 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ A large monorepo accumulates:
|
|
|
24
24
|
- **One source of truth for config** — every project just `extends @hublo/sentinel/...`; the actual rules live in one versioned place. Change a rule once, everyone gets it on the next version bump.
|
|
25
25
|
- **One source of truth for tooling dependencies** — a project depends on `@hublo/sentinel`, not on a scattered pile of eslint / vitest / plugin devDeps. Bump one version and the whole toolchain moves, atomically, tested in isolation first.
|
|
26
26
|
- **`--init` sets a module up** — the one command generates the stubs the first time (**adopt**), regenerates them after a change like a runner swap (**refresh**), and applies the workspace prep the module needs. Run it module by module to roll out gradually. (`--migrate`, for changing an already-initialized setup, is a reserved future verb.)
|
|
27
|
+
- **`--validate` proves the move did not change anything** — a verb of its own, because what separates it from `--init` is what SURVIVES the run: `--init` and `--migrate` write state that REMAINS, `--validate` may instrument the module it is proving provided nothing it writes stays. Run before a migration it records what the module does today; run after, it compares and refuses a run that lost a test or invented one. It composes with every type, and answers "not available yet" for the ones without a proof rather than calling a valid command wrong.
|
|
27
28
|
- **Move one module at a time** — installed per module, so you adopt at your pace; a module can adopt sentinel while its neighbour keeps the old setup. No big-bang.
|
|
28
29
|
- **Swap tools without touching projects** — change eslint → biome (or benchmark them) in one place; `--init` regenerates the stubs.
|
|
29
30
|
- **No silent drift** — the guard keeps every project's config converged on the source of truth.
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -14,118 +14,33 @@ import {
|
|
|
14
14
|
dispatch,
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
|
-
resolve
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
resolve
|
|
18
|
+
} from "../chunk-GRK2KRFI.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveContext
|
|
21
|
+
} from "../chunk-DYJ6J43A.js";
|
|
22
|
+
import "../chunk-O7REVMOC.js";
|
|
23
|
+
import "../chunk-QXFCZON7.js";
|
|
20
24
|
import {
|
|
21
|
-
WORKSPACE_ROOT_MARKER,
|
|
22
25
|
ensureWorkspacePrep,
|
|
23
26
|
findWorkspaceRoot,
|
|
24
27
|
inspectWorkspacePrep,
|
|
25
|
-
moduleName,
|
|
26
28
|
readOwnVersion,
|
|
27
29
|
readProjectPackageJson
|
|
28
|
-
} from "../chunk-
|
|
30
|
+
} from "../chunk-KMKQDGI6.js";
|
|
29
31
|
|
|
30
32
|
// bin/sentinel.ts
|
|
31
33
|
import { program } from "commander";
|
|
32
34
|
|
|
33
|
-
// src/core/context.ts
|
|
34
|
-
import { existsSync } from "fs";
|
|
35
|
-
import { join as join2 } from "path";
|
|
36
|
-
|
|
37
|
-
// src/core/discover-modules.ts
|
|
38
|
-
import { execFileSync } from "child_process";
|
|
39
|
-
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
40
|
-
import { tmpdir } from "os";
|
|
41
|
-
import { join } from "path";
|
|
42
|
-
function runNx(cwd2, args) {
|
|
43
|
-
const nx = resolveBin(cwd2, "nx") ?? "nx";
|
|
44
|
-
try {
|
|
45
|
-
return execFileSync(nx, args, {
|
|
46
|
-
cwd: cwd2,
|
|
47
|
-
encoding: "utf8",
|
|
48
|
-
env: { ...process.env, NX_DAEMON: "false" }
|
|
49
|
-
});
|
|
50
|
-
} catch (error) {
|
|
51
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
-
throw new Error(
|
|
53
|
-
`sentinel: could not run nx (${message}). Is nx installed in this workspace, and are you at its root?`,
|
|
54
|
-
{ cause: error }
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function readGraph(cwd2) {
|
|
59
|
-
const dir = mkdtempSync(join(tmpdir(), "sentinel-nx-"));
|
|
60
|
-
const file = join(dir, "graph.json");
|
|
61
|
-
try {
|
|
62
|
-
runNx(cwd2, ["graph", "--file", file]);
|
|
63
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
-
const nodes = parsed.graph?.nodes;
|
|
65
|
-
if (!nodes || typeof nodes !== "object") {
|
|
66
|
-
throw new Error(
|
|
67
|
-
"sentinel: unexpected nx graph output (no graph.nodes); the installed nx version may be incompatible."
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
return Object.entries(nodes).map(([name, node]) => ({
|
|
71
|
-
name,
|
|
72
|
-
root: join(cwd2, node.data.root)
|
|
73
|
-
}));
|
|
74
|
-
} finally {
|
|
75
|
-
rmSync(dir, { recursive: true, force: true });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function discoverModules(cwd2, options = {}) {
|
|
79
|
-
const modules = readGraph(cwd2);
|
|
80
|
-
if (!options.affected) return modules;
|
|
81
|
-
const affected = new Set(
|
|
82
|
-
JSON.parse(runNx(cwd2, ["show", "projects", "--affected", "--json"]))
|
|
83
|
-
);
|
|
84
|
-
return modules.filter((module) => affected.has(module.name));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// src/core/context.ts
|
|
88
|
-
var MODULE_MARKERS = ["package.json", "project.json"];
|
|
89
|
-
function isModuleDir(cwd2) {
|
|
90
|
-
return MODULE_MARKERS.some((marker) => existsSync(join2(cwd2, marker)));
|
|
91
|
-
}
|
|
92
|
-
function resolveContext(cwd2, opts2) {
|
|
93
|
-
const atRoot = existsSync(join2(cwd2, WORKSPACE_ROOT_MARKER));
|
|
94
|
-
if (!atRoot && isModuleDir(cwd2)) {
|
|
95
|
-
if (opts2.module) {
|
|
96
|
-
throw new Error(
|
|
97
|
-
"You are in a module directory: drop --module (the context is the current module)."
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
if (opts2.ci) {
|
|
101
|
-
throw new Error("--ci selects the affected set from the workspace root; run it there.");
|
|
102
|
-
}
|
|
103
|
-
const name = moduleName(cwd2);
|
|
104
|
-
return { modules: [{ name, root: cwd2 }], scope: "cwd-module" };
|
|
105
|
-
}
|
|
106
|
-
if (atRoot) {
|
|
107
|
-
if (opts2.module) {
|
|
108
|
-
const found = discoverModules(cwd2).find((module) => module.name === opts2.module);
|
|
109
|
-
if (!found) throw new Error(`module "${opts2.module}" not found in the workspace.`);
|
|
110
|
-
return { modules: [found], scope: "named-module" };
|
|
111
|
-
}
|
|
112
|
-
if (opts2.ci) return { modules: discoverModules(cwd2, { affected: true }), scope: "affected" };
|
|
113
|
-
return { modules: discoverModules(cwd2), scope: "all" };
|
|
114
|
-
}
|
|
115
|
-
throw new Error(
|
|
116
|
-
`Run sentinel from a module directory or the workspace root (found neither ${MODULE_MARKERS.join("/")} nor ${WORKSPACE_ROOT_MARKER} here).`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
35
|
// src/roles/build/declined.ts
|
|
121
|
-
import { existsSync
|
|
122
|
-
import { join
|
|
36
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
37
|
+
import { join } from "node:path";
|
|
123
38
|
var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
|
|
124
39
|
function buildsWithDeprecatedExecutor(cwd2) {
|
|
125
|
-
const projectJson =
|
|
126
|
-
if (!
|
|
40
|
+
const projectJson = join(cwd2, "project.json");
|
|
41
|
+
if (!existsSync(projectJson)) return false;
|
|
127
42
|
try {
|
|
128
|
-
const project = JSON.parse(
|
|
43
|
+
const project = JSON.parse(readFileSync(projectJson, "utf8"));
|
|
129
44
|
return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
|
|
130
45
|
} catch {
|
|
131
46
|
return false;
|
|
@@ -218,6 +133,32 @@ sentinel (${type}): ${asMessage(error)}
|
|
|
218
133
|
return worst;
|
|
219
134
|
}
|
|
220
135
|
|
|
136
|
+
// src/cli/run-validate.ts
|
|
137
|
+
async function runValidate(ctx) {
|
|
138
|
+
const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
|
|
139
|
+
const unsupported = ctx.targets.filter((target) => target !== "test");
|
|
140
|
+
if (unsupported.length > 0) {
|
|
141
|
+
const named = unsupported.map((target) => `--${target}`).join(", ");
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
|
|
144
|
+
`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!ctx.targets.includes("test")) return 1;
|
|
148
|
+
const { validateModuleTests } = await import("../validate-57H7GK5B.js");
|
|
149
|
+
const outcomes = modules.flatMap(
|
|
150
|
+
(module) => validateModuleTests(module.name, module.root)
|
|
151
|
+
);
|
|
152
|
+
for (const outcome of outcomes) {
|
|
153
|
+
const suite = outcome.suite === "main" ? "" : ` [${outcome.suite}]`;
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
` ${outcome.ok ? "\u2713" : "\u2717"} ${outcome.module}${suite} \u2014 ${outcome.message}
|
|
156
|
+
`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
221
162
|
// src/core/orchestrate.ts
|
|
222
163
|
async function analyse(params) {
|
|
223
164
|
const results = [];
|
|
@@ -645,13 +586,16 @@ program.name("sentinel").description("One CLI that guards code health: presets,
|
|
|
645
586
|
"before",
|
|
646
587
|
[
|
|
647
588
|
"A check composes: verb + type + location.",
|
|
648
|
-
" verb what to do: --run --inspect --report --status --init (--migrate: planned)",
|
|
589
|
+
" verb what to do: --run --inspect --report --status --init --validate (--migrate: planned)",
|
|
649
590
|
" type which check: --lint --typescript ... (omit = all types; or --all)",
|
|
650
591
|
" where run from a MODULE dir \u2192 that module; from the ROOT \u2192 --module <name>,",
|
|
651
592
|
" --ci (affected), or all modules. --init targets one module only.",
|
|
652
593
|
""
|
|
653
594
|
].join("\n")
|
|
654
|
-
).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option(
|
|
595
|
+
).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option(
|
|
596
|
+
"--validate",
|
|
597
|
+
"prove a migration kept the suite: record the reference, then compare against it"
|
|
598
|
+
).option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
|
|
655
599
|
"--module <name>",
|
|
656
600
|
"from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
|
|
657
601
|
).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
|
|
@@ -783,7 +727,7 @@ async function main() {
|
|
|
783
727
|
dryRun: Boolean(opts.dryRun),
|
|
784
728
|
fail: (message) => program.error(message)
|
|
785
729
|
};
|
|
786
|
-
const exitCode = verb === "init" ? await runInit(context) : await runVerb(context);
|
|
730
|
+
const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);
|
|
787
731
|
await exitWithoutTruncating(exitCode);
|
|
788
732
|
}
|
|
789
733
|
main().catch((error) => {
|
|
@@ -1,13 +1,90 @@
|
|
|
1
1
|
import {
|
|
2
2
|
decoratorMetadata,
|
|
3
3
|
tsconfigAliases
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-SWWQ7X7B.js";
|
|
5
5
|
|
|
6
6
|
// src/roles/test/shared-test-config.ts
|
|
7
|
-
import { existsSync } from "fs";
|
|
8
|
-
import { createRequire } from "module";
|
|
9
|
-
import { dirname, join } from "path";
|
|
10
|
-
import { fileURLToPath } from "url";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { dirname, join as join2 } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
// src/roles/test/isolation-split.ts
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { globSync } from "tinyglobby";
|
|
16
|
+
var DECLARES_A_MODULE_MOCK = /\b(?:vi|jest)\.(?:mock|doMock)\s*\(/;
|
|
17
|
+
function testFilesUnder(root, include) {
|
|
18
|
+
return globSync([...include], { cwd: root, ignore: ["**/node_modules/**"] }).map(String).sort();
|
|
19
|
+
}
|
|
20
|
+
function splitByModuleMock(root, files) {
|
|
21
|
+
const mocked = [];
|
|
22
|
+
const shared = [];
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const source = readSource(join(root, file));
|
|
25
|
+
if (source !== void 0 && DECLARES_A_MODULE_MOCK.test(source)) mocked.push(file);
|
|
26
|
+
else shared.push(file);
|
|
27
|
+
}
|
|
28
|
+
return { mocked, shared };
|
|
29
|
+
}
|
|
30
|
+
function asIsolationSplit(config, root) {
|
|
31
|
+
const include = config.test?.include ?? [];
|
|
32
|
+
const files = testFilesUnder(root, include);
|
|
33
|
+
if (files.length === 0) return { ...config, test: { ...config.test, isolate: false } };
|
|
34
|
+
const { mocked, shared } = splitByModuleMock(root, files);
|
|
35
|
+
return {
|
|
36
|
+
...config,
|
|
37
|
+
test: {
|
|
38
|
+
// Kept at the root, where a project cannot answer for the whole run.
|
|
39
|
+
coverage: config.test?.coverage,
|
|
40
|
+
projects: [project(config, "shared", shared, false), project(config, "mocked", mocked, true)]
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function project(config, name, include, isolate) {
|
|
45
|
+
return {
|
|
46
|
+
...config,
|
|
47
|
+
test: {
|
|
48
|
+
...config.test,
|
|
49
|
+
name,
|
|
50
|
+
include,
|
|
51
|
+
isolate,
|
|
52
|
+
/*
|
|
53
|
+
* A project with nothing to run is still cheaper than a conditional here: Vitest starts it,
|
|
54
|
+
* finds no file and reports none. Leaving both projects in place keeps the shape of the
|
|
55
|
+
* config the same for every module, which is what makes a generated config reviewable.
|
|
56
|
+
*/
|
|
57
|
+
coverage: void 0
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function readSource(path) {
|
|
62
|
+
try {
|
|
63
|
+
return readFileSync(path, "utf8");
|
|
64
|
+
} catch {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/roles/test/msw-server-redirect.ts
|
|
70
|
+
var WORKSPACE_MSW_SERVER = /(^|\/)msw\/server\.[cm]?[jt]sx?$/;
|
|
71
|
+
function mswServerRedirect(serverFile, workspaceRoot) {
|
|
72
|
+
return {
|
|
73
|
+
name: "sentinel:msw-server-redirect",
|
|
74
|
+
// Before the resolver settles on the workspace file, since the point is to replace it.
|
|
75
|
+
enforce: "pre",
|
|
76
|
+
async resolveId(source, importer, options) {
|
|
77
|
+
if (source === serverFile) return void 0;
|
|
78
|
+
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
|
|
79
|
+
if (resolved === null || resolved.external) return void 0;
|
|
80
|
+
const id = resolved.id;
|
|
81
|
+
if (!id.startsWith(workspaceRoot)) return void 0;
|
|
82
|
+
if (id.includes("/node_modules/")) return void 0;
|
|
83
|
+
if (!WORKSPACE_MSW_SERVER.test(id)) return void 0;
|
|
84
|
+
return serverFile;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
11
88
|
|
|
12
89
|
// src/roles/test/react/jest-export-conditions.ts
|
|
13
90
|
var ABSENT_UNDER_JEST = /* @__PURE__ */ new Set(["development", "development|production"]);
|
|
@@ -72,8 +149,18 @@ function vitestMockExtendedPath() {
|
|
|
72
149
|
}
|
|
73
150
|
}
|
|
74
151
|
}
|
|
152
|
+
function mswServerFile() {
|
|
153
|
+
return siblingFile("./setup/msw-server.js");
|
|
154
|
+
}
|
|
155
|
+
function nextI18nextAlias(root, workspaceRoot) {
|
|
156
|
+
for (const base of [root, workspaceRoot]) {
|
|
157
|
+
const esm = join2(base, "node_modules", "next-i18next", "dist", "esm", "index.js");
|
|
158
|
+
if (existsSync(esm)) return [{ find: /^next-i18next$/, replacement: esm }];
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
75
162
|
function mswAliases() {
|
|
76
|
-
const server =
|
|
163
|
+
const server = mswServerFile();
|
|
77
164
|
const own = resolveFrom("msw");
|
|
78
165
|
return [
|
|
79
166
|
...own === void 0 ? [] : [{ find: /^msw$/, replacement: own }],
|
|
@@ -82,25 +169,25 @@ function mswAliases() {
|
|
|
82
169
|
}
|
|
83
170
|
function luxonAlias(root, workspaceRoot) {
|
|
84
171
|
for (const base of [root, workspaceRoot]) {
|
|
85
|
-
const manifest =
|
|
172
|
+
const manifest = join2(base, "node_modules", "luxon", "package.json");
|
|
86
173
|
if (existsSync(manifest)) return [{ find: /^luxon$/, replacement: dirname(manifest) }];
|
|
87
174
|
}
|
|
88
175
|
return [];
|
|
89
176
|
}
|
|
90
177
|
function axiosAlias(root, workspaceRoot) {
|
|
91
178
|
for (const base of [root, workspaceRoot]) {
|
|
92
|
-
const manifest =
|
|
179
|
+
const manifest = join2(base, "node_modules", "axios", "package.json");
|
|
93
180
|
if (existsSync(manifest)) return [{ find: /^axios$/, replacement: dirname(manifest) }];
|
|
94
181
|
}
|
|
95
182
|
return [];
|
|
96
183
|
}
|
|
97
184
|
function prismaRuntimeAlias(workspaceRoot) {
|
|
98
|
-
const prisma =
|
|
185
|
+
const prisma = join2(workspaceRoot, "node_modules", "@prisma");
|
|
99
186
|
if (!existsSync(prisma)) return [];
|
|
100
187
|
return [
|
|
101
188
|
{
|
|
102
189
|
find: /^@prisma\/([^/]+)\/runtime\/library$/,
|
|
103
|
-
replacement:
|
|
190
|
+
replacement: join2(prisma, "$1", "runtime", "library.js")
|
|
104
191
|
}
|
|
105
192
|
];
|
|
106
193
|
}
|
|
@@ -158,6 +245,13 @@ function baseConfig(options) {
|
|
|
158
245
|
*/
|
|
159
246
|
plugins: [
|
|
160
247
|
jestExportConditions(),
|
|
248
|
+
/*
|
|
249
|
+
* The alias beside it covers a file naming the workspace's msw server by its package
|
|
250
|
+
* specifier. This covers the one that reaches the same module RELATIVELY, which an alias
|
|
251
|
+
* cannot: `./server` is a spelling thousands of unrelated files use, so the redirect is
|
|
252
|
+
* decided on the resolved path instead. See `msw-server-redirect.ts` for the measurement.
|
|
253
|
+
*/
|
|
254
|
+
...mswServerFile() === void 0 ? [] : [mswServerRedirect(mswServerFile(), workspaceRoot)],
|
|
161
255
|
...options.lowerDecoratorsWithTypeScript ? [decoratorMetadata({ root })] : []
|
|
162
256
|
],
|
|
163
257
|
/*
|
|
@@ -192,6 +286,7 @@ function baseConfig(options) {
|
|
|
192
286
|
alias: [
|
|
193
287
|
...axiosAlias(root, workspaceRoot),
|
|
194
288
|
...luxonAlias(root, workspaceRoot),
|
|
289
|
+
...nextI18nextAlias(root, workspaceRoot),
|
|
195
290
|
/*
|
|
196
291
|
* Nest only, and measured: 1186 files under `apps/nest` and `libs/nest` mention `@prisma/`,
|
|
197
292
|
* and ZERO under `apps/front` and `libs/front`. A React module paying for an alias to a
|
|
@@ -223,6 +318,37 @@ function baseConfig(options) {
|
|
|
223
318
|
globals: true,
|
|
224
319
|
environment: "node",
|
|
225
320
|
root,
|
|
321
|
+
/*
|
|
322
|
+
* The per-test budget, widened from Vitest's 5000 ms default.
|
|
323
|
+
*
|
|
324
|
+
* ⚠️ NOT because the migration made anything slower. Measured on the same machine, the same
|
|
325
|
+
* files, both runners:
|
|
326
|
+
*
|
|
327
|
+
* regulation-balance, first test jest 2283 ms vitest 2411 ms
|
|
328
|
+
* prisma-breaking-change-detector jest 2558 ms vitest 2363 ms
|
|
329
|
+
* planning-requirement, first test vitest 1972 ms
|
|
330
|
+
*
|
|
331
|
+
* Vitest is within 6% on the slowest and FASTER on the others, and faster overall on every
|
|
332
|
+
* file (regulation-balance: 3.40 s under jest, 2.23 s under vitest). What these tests have
|
|
333
|
+
* in common is that they pay a one-off cost inside a test: loading a Nest module graph, or
|
|
334
|
+
* spawning a Prisma CLI process. Against 5000 ms that is a margin of about TWO, and a shared
|
|
335
|
+
* CI agent running three tasks at once eats it. All three timed out on CI under Vitest; on
|
|
336
|
+
* that evidence jest was simply on the lucky side of the same line.
|
|
337
|
+
*
|
|
338
|
+
* 15000 ms is six times the slowest measured test, so an agent three times slower than this
|
|
339
|
+
* machine still has a factor of two in hand.
|
|
340
|
+
*
|
|
341
|
+
* ⚠️ What it costs, said plainly: a test that HANGS now takes 15 s to fail instead of 5. It
|
|
342
|
+
* hides nothing else, since a hang still fails. A module that declared its own budget keeps
|
|
343
|
+
* it, because the module's own config is merged over this one.
|
|
344
|
+
*/
|
|
345
|
+
testTimeout: 15e3,
|
|
346
|
+
/*
|
|
347
|
+
* The same budget for hooks. jest's one `testTimeout` covered both; Vitest splits them and
|
|
348
|
+
* defaults `hookTimeout` to 10 s, which is how `mission`'s `beforeAll` container start was
|
|
349
|
+
* cut off at 10012 ms. A hook doing the work a test does needs the budget a test has.
|
|
350
|
+
*/
|
|
351
|
+
hookTimeout: 15e3,
|
|
226
352
|
/*
|
|
227
353
|
* What the repo's jest preset actually matched, copied rather than approximated:
|
|
228
354
|
* `**\/?(*.)+(spec|test).[jt]s?(x)`.
|
|
@@ -281,7 +407,42 @@ function baseConfig(options) {
|
|
|
281
407
|
* A module that declared its own concurrency overrides this, in both environments, exactly as
|
|
282
408
|
* it does today.
|
|
283
409
|
*/
|
|
284
|
-
|
|
410
|
+
/*
|
|
411
|
+
* ⚠️ PARALLEL on CI too, since 26/09, and that reverses what the note above decided.
|
|
412
|
+
*
|
|
413
|
+
* The transposition was faithful and expensive. Measured on `libs/front/components`, 1418
|
|
414
|
+
* tests green on every line, on the repo's own 4-core agents:
|
|
415
|
+
*
|
|
416
|
+
* jest, as CI runs it 158.7 s
|
|
417
|
+
* vitest serial, the transposition 644.5 s 4.1x slower
|
|
418
|
+
* vitest, 3 workers 324.7 s 2.05x slower
|
|
419
|
+
*
|
|
420
|
+
* Serialising costs vitest far more than it costs jest, because vitest re-evaluates the
|
|
421
|
+
* module graph per file through Vite's SSR runner: 555 ms of `import` and 478 ms of
|
|
422
|
+
* `environment` per file against 139 ms of actual tests. Parallelism does not remove that
|
|
423
|
+
* cost, it pays it on several cores at once.
|
|
424
|
+
*
|
|
425
|
+
* Every other documented lever was measured and does nothing or breaks the suite:
|
|
426
|
+
* `deps.optimizer.ssr`, `NODE_COMPILE_CACHE` and `fsModuleCache` all cache TRANSFORMATION
|
|
427
|
+
* where the cost is EVALUATION; `pool: threads` is noise; `vmThreads` collapses into
|
|
428
|
+
* `no-isolate` when serial and fails 473 tests; `--no-isolate` fails 351.
|
|
429
|
+
*
|
|
430
|
+
* ⚠️ And NO worker cap, which is deliberate after reading vitest's own sizing:
|
|
431
|
+
*
|
|
432
|
+
* getDefaultThreadsCount: config.watch ? max(numCpus / 2, 1) : max(numCpus - 1, 1)
|
|
433
|
+
*
|
|
434
|
+
* `cores - 1` is 3 on this repo's 4-core agents, which is exactly the pool the 324.7 s above
|
|
435
|
+
* was measured with, and 9 on a 10-core laptop. Pinning a number would slow every developer
|
|
436
|
+
* to serve the agent, and a percentage would only re-describe a default that already adapts.
|
|
437
|
+
* So one variable changes here and the tool keeps sizing its own pool.
|
|
438
|
+
*
|
|
439
|
+
* Memory was the reason to hesitate and it was measured rather than assumed: a full jsdom
|
|
440
|
+
* run holds 1.1 GB at three workers and 1.9 GB at six, on agents with 32 GB. The OOM this
|
|
441
|
+
* repo suffered was three nx tasks near 7.45 GB each, which is not what a test worker costs.
|
|
442
|
+
*
|
|
443
|
+
* A module that declares its own concurrency still overrides this, as before.
|
|
444
|
+
*/
|
|
445
|
+
fileParallelism: true,
|
|
285
446
|
/*
|
|
286
447
|
* ⚠️ Sentinel's own setup files go through VITE, not round it, and that is load-bearing.
|
|
287
448
|
*
|
|
@@ -309,8 +470,25 @@ function baseConfig(options) {
|
|
|
309
470
|
* The narrow form `/@hublo\/sentinel\/dist\/roles\//` was tried and does NOT work: the pin
|
|
310
471
|
* itself lives in a shared chunk at the dist root, which stays external. It has to be the
|
|
311
472
|
* whole package.
|
|
473
|
+
*
|
|
474
|
+
* ## ⚠️ And `vitest` itself, for the same reason one level further out
|
|
475
|
+
*
|
|
476
|
+
* Inlining sentinel is not enough: its own `import { expect } from 'vitest'` still leaves
|
|
477
|
+
* through Node, which loads a SECOND instance of the runner. The snapshot client is
|
|
478
|
+
* per-instance state, so the instance the test file uses was never set up for that file:
|
|
479
|
+
*
|
|
480
|
+
* Error: The snapshot state for '…/asyncapi.module.spec.ts' is not found.
|
|
481
|
+
* Did you call 'SnapshotClient.setup()'?
|
|
482
|
+
*
|
|
483
|
+
* Isolated to the smallest possible case: an EMPTY setup file under `node_modules` passes,
|
|
484
|
+
* and the same file containing nothing but `import { expect } from 'vitest'` fails, while
|
|
485
|
+
* the identical import from a setup file INSIDE the module passes. So it is the location,
|
|
486
|
+
* not the import, and the remedy has to reach the import too.
|
|
487
|
+
*
|
|
488
|
+
* Measured on `libs/nest/asyncapi`: sentinel alone 1 failed of 13, sentinel and `vitest`
|
|
489
|
+
* 13 passed, 525 ms against 570 ms. Not a trade.
|
|
312
490
|
*/
|
|
313
|
-
server: { deps: { inline: [/@hublo\/sentinel
|
|
491
|
+
server: { deps: { inline: [/@hublo\/sentinel/, "vitest"] } }
|
|
314
492
|
}
|
|
315
493
|
};
|
|
316
494
|
}
|
|
@@ -323,6 +501,39 @@ function asAliasArray(alias) {
|
|
|
323
501
|
}));
|
|
324
502
|
}
|
|
325
503
|
function sharedTestConfig(options) {
|
|
504
|
+
return withIsolationChoice(mergedConfig(options), options);
|
|
505
|
+
}
|
|
506
|
+
function withIsolationChoice(config, options) {
|
|
507
|
+
if (options.isolate !== false) return config;
|
|
508
|
+
if (options.flavour === "nest") {
|
|
509
|
+
throw new Error(
|
|
510
|
+
"sentinel test: `isolate: false` is not available for a Nest suite. Nest registers its metadata as an import side effect, so sharing a module registry lets one suite see what another registered. Measured on `agency`, it would save 7% (43s to 40s) where a React module saves 71%, so the trade is not worth making. Remove the option."
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const bracketed = {
|
|
514
|
+
...config,
|
|
515
|
+
test: {
|
|
516
|
+
...config.test,
|
|
517
|
+
/*
|
|
518
|
+
* The boundary brackets the module's own setup files rather than joining them. The closing
|
|
519
|
+
* entry has to run after the module's, because what it records as "infrastructure" is
|
|
520
|
+
* whatever the setup files built, and `setupFiles` order is the only way to say "last".
|
|
521
|
+
*/
|
|
522
|
+
setupFiles: [
|
|
523
|
+
"@hublo/sentinel/test/setup/file-boundary",
|
|
524
|
+
...asSetupList(config.test?.setupFiles),
|
|
525
|
+
"@hublo/sentinel/test/setup/file-boundary-close"
|
|
526
|
+
]
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
return asIsolationSplit(bracketed, options.root);
|
|
530
|
+
}
|
|
531
|
+
function asSetupList(setupFiles) {
|
|
532
|
+
if (typeof setupFiles === "string") return [setupFiles];
|
|
533
|
+
if (Array.isArray(setupFiles)) return setupFiles;
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
function mergedConfig(options) {
|
|
326
537
|
const base = baseConfig(options);
|
|
327
538
|
if (options.overrides === void 0) return base;
|
|
328
539
|
return {
|
|
@@ -394,4 +605,4 @@ export {
|
|
|
394
605
|
jestExportConditions,
|
|
395
606
|
sharedTestConfig
|
|
396
607
|
};
|
|
397
|
-
//# sourceMappingURL=chunk-
|
|
608
|
+
//# sourceMappingURL=chunk-5VNYQIFD.js.map
|