mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ci(server): module mocks, hand-built services and test file length in tests/ may only shrink
lint:test-mocks counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db per file under tests/, lint:test-new-service counts new XService(/new XRepository( there with every class whose constructor takes nothing allowed (derived from src/ and tests/helpers/), and lint:size holds files under tests/ to 2000 lines against their own baseline. Both counters share scripts/lib/count-ratchet.mjs, which gains a prepare hook for a check that reads the tree first. The three run as steps of the existing server lint job; the CLAUDE.md files and the module guide describe the test helpers they point to.
This commit is contained in:
@@ -280,6 +280,8 @@ jobs:
|
||||
# A source file may hold no more lines than scripts/size-baseline.json
|
||||
# allows (1000 for a file without an entry, long lines counted
|
||||
# wrapped), so a big service is split by concern instead of growing.
|
||||
# Files under tests/ are held the same way at 2000 lines against
|
||||
# scripts/test-size-baseline.json.
|
||||
run: cd server && npm run lint:size
|
||||
|
||||
- name: Shared ratchet code
|
||||
@@ -311,6 +313,18 @@ jobs:
|
||||
# through tests/helpers/factories (MikroORM) instead.
|
||||
run: cd server && npm run lint:test-sql
|
||||
|
||||
- name: Module mocks of config, realtime and db in tests may only shrink
|
||||
# Counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db
|
||||
# per file under tests/ against scripts/test-mocks-baseline.json. The
|
||||
# setup file fixes the config, a FakeRealtimeService records broadcasts.
|
||||
run: cd server && npm run lint:test-mocks
|
||||
|
||||
- name: Hand-built services in tests may only shrink
|
||||
# Counts new XService(/new XRepository( per file under tests/ against
|
||||
# scripts/test-new-service-baseline.json (classes whose constructor
|
||||
# takes nothing are allowed). New suites use createTestModule.
|
||||
run: cd server && npm run lint:test-new-service
|
||||
|
||||
- name: QueryBuilder calls in the repositories may only shrink
|
||||
# Counts this.qb(...)/createQueryBuilder(...) per file under
|
||||
# src/db/repositories/ against scripts/query-api-baseline.json. New
|
||||
|
||||
@@ -52,6 +52,8 @@ cd server && npm run lint:dialect # SQLite-only SQL spellings m
|
||||
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
|
||||
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
|
||||
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
|
||||
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
|
||||
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
|
||||
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
|
||||
@@ -130,7 +132,7 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
|
||||
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
|
||||
|
||||
+6
-2
@@ -22,12 +22,14 @@ npm run gen:db-types # regenerate the Kysely table types in src/db/kysely/
|
||||
npm run check:db-types # CI gate: fails when src/db/kysely/ no longer matches the migrations
|
||||
node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts
|
||||
npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any)
|
||||
npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate)
|
||||
npm run lint:size # no file in src/ or scripts/ past 1000 lines, none in tests/ past 2000, longer ones only shrink (CI gate)
|
||||
npm run lint:boundaries # import cycles and cross-domain reach-ins may only shrink (CI gate)
|
||||
npm run lint:dialect # SQLite-only SQL spellings under src/ may only shrink (CI gate)
|
||||
npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job postgres-probe; needs TREK_PG_PROBE_URL)
|
||||
npm run lint:tx # methods writing more than once outside one transaction may only shrink (CI gate)
|
||||
npm run lint:test-sql # raw better-sqlite3 statements in tests/ may only shrink (CI gate)
|
||||
npm run lint:test-mocks # vi.mock of src/config, src/nest/realtime and src/db in tests/ may only shrink (CI gate)
|
||||
npm run lint:test-new-service # new XService(/new XRepository( in tests/ may only shrink; zero-argument classes are allowed (CI gate)
|
||||
npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
|
||||
npm run lint:mcp-zod # inline z. calls per *.mcp.ts may only shrink; tool fields derive from @trek/shared (CI gate)
|
||||
npm run lint:service-http # error-envelope idioms (status: 4xx in services, if (result.error) in callers) may only shrink (CI gate)
|
||||
@@ -41,12 +43,14 @@ npm run contracts:dto-open # open shapes in the createZodDto schemas may only sh
|
||||
|
||||
**`lint:tx`** (`scripts/tx-writes.mjs`) reads every class under `src/` and counts, per method, the writes it issues outside a `.transactional(...)` callback: a repository call that builds an INSERT/UPDATE/DELETE, a call to a method that writes (of its own class or of a constructor-injected one), a transaction holding writes as one. A write in a loop counts twice, of two branches only the larger counts, and a helper whose every call sits inside a transaction is not reported. A method at two or more is held to its entry in `scripts/tx-writes-baseline.json`; a new one fails, and so does an entry above what its method holds now, so a fix lands with `npm run lint:tx -- --update` (it never raises or adds an entry). `--explain` lists the line of every call that counted. Two JSDoc tags mark the deliberate cases, each with its reason in the tag text: `@txStandalone` on a method whose write stands on its own whatever its caller does (an audit row, a notification, a non-fatal derived refresh, an idempotent lazy provision) makes it count for nothing at its callers, and `@txIndependent` on a method whose writes are complete one by one on purpose (network I/O between them, a retention sweep in passes, one transaction per item of a batch) keeps it out of the report.
|
||||
|
||||
**`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry).
|
||||
**`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files under `tests/` are held the same way at 2000 lines against `scripts/test-size-baseline.json`. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry).
|
||||
|
||||
**`lint:strict`** (`scripts/strict-lint.mjs`) runs tsc over `tsconfig.strict.json`, the build config with `strictNullChecks` and `noImplicitAny` on, and counts the errors per file against `scripts/strict-baseline.json`. A file may hold at most its entry and a file without one, a new file in particular, none; an entry above what its file holds now, or for a file that is gone, fails as well, so the change that fixes errors runs `npm run lint:strict -- --update` (it never raises or adds an entry). It is a ratchet only: the build stays on `strict: false` until the baseline is empty.
|
||||
|
||||
**`lint:test-sql`** (`scripts/test-sql-ratchet.mjs`) counts every `.prepare(` under `tests/` per file against `scripts/test-sql-baseline.json`: a file may hold at most its entry, a file without one none. Growth fails, and so does an entry above what its file holds now, so a conversion lands with `npm run lint:test-sql -- --update` (it only lowers and drops entries). Files whose subject is raw SQL (the dialect functions, PRAGMA introspection, the legacy upgrade fixtures) sit in the baseline's `exempt` list with a reason; a single statement that must stay raw carries a `test-sql-allow: <reason>` comment on its line or the line above. `tests/unit/scripts/test-sql-ratchet.test.ts` also runs it on the tree.
|
||||
|
||||
**`lint:test-mocks`** (`scripts/test-mocks-ratchet.mjs`) and **`lint:test-new-service`** (`scripts/test-new-service-ratchet.mjs`) share the count rules of `scripts/lib/count-ratchet.mjs` (`scripts/test-mocks-baseline.json`, `scripts/test-new-service-baseline.json`). The first counts every `vi.mock`/`vi.doMock` of `src/config`, `src/nest/realtime/**` and `src/db/**` per file under `tests/`: the setup file fixes the config (`tests/helpers/test-config.ts`, `overrideTestConfig`), broadcasts go to a `FakeRealtimeService` or `spyOnRealtime(app)`. The second counts `new <X>Service(` and `new <X>Repository(` under `tests/`, leaving out classes whose constructor takes nothing (derived from `src/` and `tests/helpers/`); a suite takes its subject from `createTestModule` (`tests/helpers/test-module.ts`) or from a booted container (`tests/helpers/test-app.ts`).
|
||||
|
||||
**`lint:query-api`** (`scripts/query-api-ratchet.mjs`) counts every `.qb(` and `createQueryBuilder(` under `src/db/repositories/` per file (the `TrekRepository` base that defines `qb` is left out) against `scripts/query-api-baseline.json`: a file may hold at most its entry, a file without one none, and an entry above what its file holds now fails until `npm run lint:query-api -- --update` lowers it (it never raises or adds one). The rule it holds is "Which query API" under SQL dialect below.
|
||||
|
||||
**`lint:mcp-zod`** (`scripts/mcp-inline-zod.mjs`) and **`lint:service-http`** (`scripts/service-http-ratchet.mjs`) share the per-file count rules of `scripts/lib/count-ratchet.mjs`: a file may hold at most its entry in its baseline (`scripts/mcp-inline-zod-baseline.json`, `scripts/service-http-baseline.json`), a file without one none, and an entry above what its file holds now fails until `-- --update` lowers it (never raises or adds). `lint:mcp-zod` counts every `z.` in a `*.mcp.ts`: a tool field derives from the request schema in `@trek/shared` or a shared primitive (`idSchema`); a bound REST deliberately lacks stays inline and counted. `lint:service-http` counts, in a `*.service.ts`, the `status: 4xx`/`5xx` literals of a returned `{ error, status }` and every `new HttpException(`, and in a `*.controller.ts` or `*.mcp.ts` every branch on a returned error (`if (result.error)`, `if ('error' in r)`). A service refuses by throwing a `DomainError` (`src/nest/common/domain-error.ts`) instead; `src/nest/README.md` ("Refusals") has the shape.
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"lint:dialect": "node scripts/dialect-lint.mjs",
|
||||
"lint:tx": "node scripts/tx-writes.mjs",
|
||||
"lint:test-sql": "node scripts/test-sql-ratchet.mjs",
|
||||
"lint:test-mocks": "node scripts/test-mocks-ratchet.mjs",
|
||||
"lint:test-new-service": "node scripts/test-new-service-ratchet.mjs",
|
||||
"lint:query-api": "node scripts/query-api-ratchet.mjs",
|
||||
"lint:mcp-zod": "node scripts/mcp-inline-zod.mjs",
|
||||
"lint:service-http": "node scripts/service-http-ratchet.mjs",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* The per-file count ratchet shared by lint:mcp-zod and lint:service-http.
|
||||
* The per-file count ratchet shared by lint:mcp-zod, lint:service-http,
|
||||
* lint:test-mocks and lint:test-new-service.
|
||||
*
|
||||
* A check names the tree it walks, which files in it count and how to count one
|
||||
* file's text. Every file is held to its entry in the check's baseline JSON
|
||||
@@ -31,11 +32,14 @@ export function scan(serverDir, check) {
|
||||
`${check.root}/ does not exist under ${serverDir}: the check would pass without looking at anything`,
|
||||
);
|
||||
}
|
||||
// A check that needs to read the tree first (a list of class names, say)
|
||||
// builds that context once here; count() gets it with every file.
|
||||
const context = check.prepare ? check.prepare(serverDir) : undefined;
|
||||
const counts = {};
|
||||
for (const path of walk(dir)) {
|
||||
const file = relative(serverDir, path).split('\\').join('/');
|
||||
if (!check.include(file)) continue;
|
||||
const n = check.count(readFileSync(path, 'utf8'), file);
|
||||
const n = check.count(readFileSync(path, 'utf8'), file, context);
|
||||
if (n > 0) counts[file] = n;
|
||||
}
|
||||
return counts;
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
* --update lowers it: otherwise the file could grow back unseen, and a new
|
||||
* file at a deleted path would inherit its allowance.
|
||||
*
|
||||
* Tests are held the same way under their own, wider limit: a file under
|
||||
* tests/ (helpers and fixtures included) may hold TEST_LIMIT lines, the longer
|
||||
* ones are listed in scripts/test-size-baseline.json, and a suite that needs
|
||||
* to grow is split by the behaviour it covers.
|
||||
*
|
||||
* Lines are counted the way they read, not the way they are stored: a line
|
||||
* longer than LINE_WIDTH (prettier's printWidth) counts once per LINE_WIDTH
|
||||
* columns it spans, so joining lines does not buy room under the limit.
|
||||
@@ -42,22 +47,42 @@ import {
|
||||
writeBaseline,
|
||||
} from '../../scripts/lib/ratchet.mjs';
|
||||
|
||||
/** The most lines a file without a baseline entry may hold. */
|
||||
/** The most lines a source file without a baseline entry may hold. */
|
||||
const LIMIT = 1000;
|
||||
|
||||
/** The most lines a test file without a baseline entry may hold. */
|
||||
const TEST_LIMIT = 2000;
|
||||
|
||||
/** prettier's printWidth for the server: a line past it counts once per width it spans. */
|
||||
const LINE_WIDTH = 120;
|
||||
|
||||
/** The trees the check walks, relative to the server root. Each one must exist. */
|
||||
const ROOTS = ['src', 'scripts'];
|
||||
|
||||
const SOURCE = /\.(?:[cm]?[jt]s)$/;
|
||||
const TEST = /\.(?:test|spec)\./;
|
||||
|
||||
const accepts = (key) => {
|
||||
const name = key.slice(key.lastIndexOf('/') + 1);
|
||||
return SOURCE.test(name) && !TEST.test(name);
|
||||
};
|
||||
const nameOf = (key) => key.slice(key.lastIndexOf('/') + 1);
|
||||
|
||||
/**
|
||||
* What the check holds. Each scope walks its trees (relative to the server
|
||||
* root; each one must exist) against its own limit and baseline file.
|
||||
*/
|
||||
const SCOPES = [
|
||||
{
|
||||
label: 'size',
|
||||
roots: ['src', 'scripts'],
|
||||
limit: LIMIT,
|
||||
baseline: 'size-baseline.json',
|
||||
accepts: (key) => SOURCE.test(nameOf(key)) && !TEST.test(nameOf(key)),
|
||||
advice: 'Split a concern into a service, helper or module of its own instead of growing the file.',
|
||||
},
|
||||
{
|
||||
label: 'size (tests)',
|
||||
roots: ['tests'],
|
||||
limit: TEST_LIMIT,
|
||||
baseline: 'test-size-baseline.json',
|
||||
accepts: (key) => SOURCE.test(nameOf(key)),
|
||||
advice: 'Split the suite by the behaviour it covers instead of growing the file.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Lines as an editor numbers them (a final newline does not start another),
|
||||
@@ -73,48 +98,48 @@ function lineCount(text) {
|
||||
}
|
||||
|
||||
/** Weighted line counts keyed by the server-relative POSIX path. A missing root is an error. */
|
||||
function scan(serverDir) {
|
||||
function scan(serverDir, scope) {
|
||||
const counts = {};
|
||||
for (const path of listFiles(serverDir, ROOTS, accepts)) counts[toKey(serverDir, path)] = lineCount(readText(path));
|
||||
for (const path of listFiles(serverDir, scope.roots, scope.accepts)) {
|
||||
counts[toKey(serverDir, path)] = lineCount(readText(path));
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Every entry must be a whole number above the limit: anything else is a hand edit gone wrong. */
|
||||
function sizeEntries(value) {
|
||||
const sizeEntries = (limit) => (value) => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return 'it must be an object of file paths to line counts';
|
||||
}
|
||||
for (const [file, n] of Object.entries(value)) {
|
||||
if (!Number.isInteger(n) || n <= LIMIT) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`;
|
||||
if (!Number.isInteger(n) || n <= limit) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${limit}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function check(argv) {
|
||||
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
||||
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
||||
const baselinePath = join(serverDir, 'scripts', 'size-baseline.json');
|
||||
/** One scope against its baseline; answers whether it failed. */
|
||||
function checkScope(serverDir, scope, update) {
|
||||
const { limit } = scope;
|
||||
const baselinePath = join(serverDir, 'scripts', scope.baseline);
|
||||
|
||||
const counts = scan(serverDir);
|
||||
let baseline = readBaseline(baselinePath, sizeEntries);
|
||||
if (argv.includes('--update')) {
|
||||
baseline = lowerCounts(baseline, counts, LIMIT);
|
||||
const counts = scan(serverDir, scope);
|
||||
let baseline = readBaseline(baselinePath, sizeEntries(limit));
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts, limit);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT));
|
||||
const stale = staleCounts(baseline, counts, LIMIT);
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, limit));
|
||||
const stale = staleCounts(baseline, counts, limit);
|
||||
for (const [file, n] of grown) {
|
||||
const entry = baseline[file];
|
||||
console.error(
|
||||
`FAIL ${file}: ${n} lines, ` +
|
||||
(entry ? `its baseline is ${entry}. ` : `the limit is ${LIMIT}. `) +
|
||||
'Split a concern into a service, helper or module of its own instead of growing the file.',
|
||||
`FAIL ${file}: ${n} lines, ` + (entry ? `its baseline is ${entry}. ` : `the limit is ${limit}. `) + scope.advice,
|
||||
);
|
||||
}
|
||||
for (const { key, entry } of stale) {
|
||||
console.error(
|
||||
`FAIL ${key} is held at ${entry} in scripts/size-baseline.json, ` +
|
||||
`FAIL ${key} is held at ${entry} in scripts/${scope.baseline}, ` +
|
||||
(key in counts ? `but it has ${counts[key]} lines now.` : 'but the file is gone.'),
|
||||
);
|
||||
}
|
||||
@@ -125,9 +150,19 @@ function check(argv) {
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`size: ${Object.keys(counts).length} file(s), ${Object.keys(baseline).length} over ${LIMIT} lines held at their baseline`,
|
||||
`${scope.label}: ${Object.keys(counts).length} file(s), ` +
|
||||
`${Object.keys(baseline).length} over ${limit} lines held at their baseline`,
|
||||
);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
return grown.length > 0 || stale.length > 0;
|
||||
}
|
||||
|
||||
function check(argv) {
|
||||
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
||||
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
||||
const update = argv.includes('--update');
|
||||
let failed = false;
|
||||
for (const scope of SCOPES) failed = checkScope(serverDir, scope, update) || failed;
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
await runCli('lint:size', (argv) => {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"counts": {
|
||||
"tests/e2e/addons.e2e.test.ts": 1,
|
||||
"tests/e2e/admin.e2e.test.ts": 1,
|
||||
"tests/e2e/airports.e2e.test.ts": 1,
|
||||
"tests/e2e/assignments.e2e.test.ts": 1,
|
||||
"tests/e2e/atlas.e2e.test.ts": 1,
|
||||
"tests/e2e/auth.e2e.test.ts": 1,
|
||||
"tests/e2e/backup.e2e.test.ts": 1,
|
||||
"tests/e2e/booking-import.e2e.test.ts": 1,
|
||||
"tests/e2e/budget.e2e.test.ts": 1,
|
||||
"tests/e2e/calendar-feed-visibility.e2e.test.ts": 1,
|
||||
"tests/e2e/categories.e2e.test.ts": 1,
|
||||
"tests/e2e/collab.e2e.test.ts": 1,
|
||||
"tests/e2e/collections.e2e.test.ts": 1,
|
||||
"tests/e2e/dawarich.e2e.test.ts": 1,
|
||||
"tests/e2e/days.e2e.test.ts": 1,
|
||||
"tests/e2e/doc-sync.e2e.test.ts": 1,
|
||||
"tests/e2e/feeds.e2e.test.ts": 1,
|
||||
"tests/e2e/files.e2e.test.ts": 1,
|
||||
"tests/e2e/google-quota.e2e.test.ts": 1,
|
||||
"tests/e2e/harness.ts": 1,
|
||||
"tests/e2e/journey.e2e.test.ts": 1,
|
||||
"tests/e2e/mailer.e2e.test.ts": 1,
|
||||
"tests/e2e/maps.e2e.test.ts": 1,
|
||||
"tests/e2e/memories.e2e.test.ts": 1,
|
||||
"tests/e2e/notifications.e2e.test.ts": 1,
|
||||
"tests/e2e/oauth.e2e.test.ts": 1,
|
||||
"tests/e2e/oidc.e2e.test.ts": 1,
|
||||
"tests/e2e/packing.e2e.test.ts": 1,
|
||||
"tests/e2e/place-enrichment.e2e.test.ts": 1,
|
||||
"tests/e2e/place-shadow.e2e.test.ts": 1,
|
||||
"tests/e2e/places.e2e.test.ts": 1,
|
||||
"tests/e2e/plugin-pois.e2e.test.ts": 1,
|
||||
"tests/e2e/plugin-search.e2e.test.ts": 1,
|
||||
"tests/e2e/public-api.e2e.test.ts": 1,
|
||||
"tests/e2e/push.e2e.test.ts": 1,
|
||||
"tests/e2e/receipt-scan.e2e.test.ts": 1,
|
||||
"tests/e2e/reservations.e2e.test.ts": 1,
|
||||
"tests/e2e/roadtrip.e2e.test.ts": 1,
|
||||
"tests/e2e/school-holidays.e2e.test.ts": 1,
|
||||
"tests/e2e/session-revocation.e2e.test.ts": 1,
|
||||
"tests/e2e/sessions.e2e.test.ts": 1,
|
||||
"tests/e2e/settings.e2e.test.ts": 1,
|
||||
"tests/e2e/share.e2e.test.ts": 1,
|
||||
"tests/e2e/storage-admin.e2e.test.ts": 1,
|
||||
"tests/e2e/system-notices.e2e.test.ts": 1,
|
||||
"tests/e2e/tags.e2e.test.ts": 1,
|
||||
"tests/e2e/todo.e2e.test.ts": 1,
|
||||
"tests/e2e/tours.e2e.test.ts": 1,
|
||||
"tests/e2e/transit-provider.e2e.test.ts": 1,
|
||||
"tests/e2e/transit.e2e.test.ts": 1,
|
||||
"tests/e2e/trip-invite.e2e.test.ts": 1,
|
||||
"tests/e2e/trips.e2e.test.ts": 1,
|
||||
"tests/e2e/vacay.e2e.test.ts": 1,
|
||||
"tests/e2e/weather.e2e.test.ts": 1,
|
||||
"tests/helpers/db-mock.ts": 3,
|
||||
"tests/helpers/test-db.ts": 1,
|
||||
"tests/integration/admin.test.ts": 1,
|
||||
"tests/integration/airtrail-sync-request-context.test.ts": 1,
|
||||
"tests/integration/api-docs.test.ts": 1,
|
||||
"tests/integration/app-config.test.ts": 1,
|
||||
"tests/integration/assignments.test.ts": 1,
|
||||
"tests/integration/async-closure-truthiness-guards.test.ts": 1,
|
||||
"tests/integration/atlas-geocode-request-context.test.ts": 1,
|
||||
"tests/integration/atlas.test.ts": 1,
|
||||
"tests/integration/auth.test.ts": 1,
|
||||
"tests/integration/backup.test.ts": 1,
|
||||
"tests/integration/boot-sweeps-request-context.test.ts": 1,
|
||||
"tests/integration/bootstrap.test.ts": 1,
|
||||
"tests/integration/budget.test.ts": 1,
|
||||
"tests/integration/categories.test.ts": 1,
|
||||
"tests/integration/collab.test.ts": 1,
|
||||
"tests/integration/collections.test.ts": 1,
|
||||
"tests/integration/dayNotes.test.ts": 1,
|
||||
"tests/integration/dayReorder.test.ts": 1,
|
||||
"tests/integration/days.test.ts": 1,
|
||||
"tests/integration/demo-seed-request-context.test.ts": 1,
|
||||
"tests/integration/doc-sync-webhook-request-context.test.ts": 1,
|
||||
"tests/integration/feeds-request-context.test.ts": 1,
|
||||
"tests/integration/files.test.ts": 1,
|
||||
"tests/integration/help.test.ts": 1,
|
||||
"tests/integration/immich.test.ts": 1,
|
||||
"tests/integration/journey-place-order.test.ts": 1,
|
||||
"tests/integration/journey.test.ts": 1,
|
||||
"tests/integration/leg-mode-incoming.test.ts": 1,
|
||||
"tests/integration/legacy-upgrade-v066.test.ts": 1,
|
||||
"tests/integration/legacy-upgrade-v082.test.ts": 1,
|
||||
"tests/integration/legacy-upgrade-v205.test.ts": 1,
|
||||
"tests/integration/legacy-upgrade-v242.test.ts": 1,
|
||||
"tests/integration/legacy-upgrade-v244.test.ts": 1,
|
||||
"tests/integration/maps.test.ts": 1,
|
||||
"tests/integration/mcp.test.ts": 1,
|
||||
"tests/integration/memories-immich.test.ts": 1,
|
||||
"tests/integration/memories-synology.test.ts": 1,
|
||||
"tests/integration/memories-unified.test.ts": 1,
|
||||
"tests/integration/misc.test.ts": 1,
|
||||
"tests/integration/notifications.test.ts": 1,
|
||||
"tests/integration/oauth-authorize.test.ts": 1,
|
||||
"tests/integration/oauth.test.ts": 1,
|
||||
"tests/integration/oidc-local-provider.test.ts": 1,
|
||||
"tests/integration/oidc.test.ts": 1,
|
||||
"tests/integration/orm-request-context-seams.test.ts": 1,
|
||||
"tests/integration/packing.test.ts": 1,
|
||||
"tests/integration/passkey.test.ts": 1,
|
||||
"tests/integration/places.test.ts": 1,
|
||||
"tests/integration/plugins/boot-registry-order.test.ts": 1,
|
||||
"tests/integration/plugins/plugin-runtime.test.ts": 1,
|
||||
"tests/integration/profile.test.ts": 1,
|
||||
"tests/integration/reservations.test.ts": 1,
|
||||
"tests/integration/scheduler-gate.test.ts": 1,
|
||||
"tests/integration/security.test.ts": 1,
|
||||
"tests/integration/settings.test.ts": 1,
|
||||
"tests/integration/share.test.ts": 1,
|
||||
"tests/integration/storage-health-notifier-request-context.test.ts": 1,
|
||||
"tests/integration/systemNotices.test.ts": 1,
|
||||
"tests/integration/tags.test.ts": 1,
|
||||
"tests/integration/todo.test.ts": 1,
|
||||
"tests/integration/trip-access-primitives.test.ts": 1,
|
||||
"tests/integration/trips.test.ts": 1,
|
||||
"tests/integration/uploads-photo-request-context.test.ts": 1,
|
||||
"tests/integration/uploads-static.test.ts": 1,
|
||||
"tests/integration/vacay.test.ts": 1,
|
||||
"tests/setup.ts": 1,
|
||||
"tests/unit/auto-backup.test.ts": 1,
|
||||
"tests/unit/db/database-lazy-open.test.ts": 1,
|
||||
"tests/unit/mcp/demo-tool-gate.test.ts": 1,
|
||||
"tests/unit/mcp/tools-addon-gating.test.ts": 1,
|
||||
"tests/unit/mcp/tools-airtrail.test.ts": 1,
|
||||
"tests/unit/mcp/tools-assignments-reservations-extra.test.ts": 1,
|
||||
"tests/unit/mcp/tools-assignments.test.ts": 1,
|
||||
"tests/unit/mcp/tools-atlas-expanded.test.ts": 1,
|
||||
"tests/unit/mcp/tools-atlas.test.ts": 1,
|
||||
"tests/unit/mcp/tools-budget-advanced.test.ts": 1,
|
||||
"tests/unit/mcp/tools-budget.test.ts": 1,
|
||||
"tests/unit/mcp/tools-categories.test.ts": 1,
|
||||
"tests/unit/mcp/tools-collab-polls-chat.test.ts": 1,
|
||||
"tests/unit/mcp/tools-collections.test.ts": 1,
|
||||
"tests/unit/mcp/tools-days-accommodations.test.ts": 1,
|
||||
"tests/unit/mcp/tools-days.test.ts": 1,
|
||||
"tests/unit/mcp/tools-feeds-invite.test.ts": 1,
|
||||
"tests/unit/mcp/tools-files.test.ts": 1,
|
||||
"tests/unit/mcp/tools-help-addons.test.ts": 1,
|
||||
"tests/unit/mcp/tools-journey.test.ts": 1,
|
||||
"tests/unit/mcp/tools-leg-transport-mode.test.ts": 1,
|
||||
"tests/unit/mcp/tools-memories.test.ts": 1,
|
||||
"tests/unit/mcp/tools-notes.test.ts": 1,
|
||||
"tests/unit/mcp/tools-notifications.test.ts": 1,
|
||||
"tests/unit/mcp/tools-packing-advanced.test.ts": 1,
|
||||
"tests/unit/mcp/tools-packing.test.ts": 1,
|
||||
"tests/unit/mcp/tools-places.test.ts": 1,
|
||||
"tests/unit/mcp/tools-plugin-pois.test.ts": 1,
|
||||
"tests/unit/mcp/tools-plugin-search.test.ts": 1,
|
||||
"tests/unit/mcp/tools-prompts.test.ts": 1,
|
||||
"tests/unit/mcp/tools-reservations.test.ts": 1,
|
||||
"tests/unit/mcp/tools-roadtrip.test.ts": 1,
|
||||
"tests/unit/mcp/tools-settings.test.ts": 1,
|
||||
"tests/unit/mcp/tools-share.test.ts": 1,
|
||||
"tests/unit/mcp/tools-tags-maps-weather.test.ts": 1,
|
||||
"tests/unit/mcp/tools-todos.test.ts": 1,
|
||||
"tests/unit/mcp/tools-transit.test.ts": 1,
|
||||
"tests/unit/mcp/tools-transports.test.ts": 1,
|
||||
"tests/unit/mcp/tools-trip-members.test.ts": 1,
|
||||
"tests/unit/mcp/tools-trip-warnings.test.ts": 1,
|
||||
"tests/unit/mcp/tools-trips.test.ts": 1,
|
||||
"tests/unit/mcp/tools-vacay.test.ts": 1,
|
||||
"tests/unit/nest/accommodations.service.test.ts": 1,
|
||||
"tests/unit/nest/admin.service.test.ts": 1,
|
||||
"tests/unit/nest/admin.version-notification.test.ts": 1,
|
||||
"tests/unit/nest/assignments.service.test.ts": 1,
|
||||
"tests/unit/nest/atlas.service.test.ts": 1,
|
||||
"tests/unit/nest/auth.service.test.ts": 1,
|
||||
"tests/unit/nest/backup.impl.test.ts": 1,
|
||||
"tests/unit/nest/booking-import/booking-import.service.test.ts": 1,
|
||||
"tests/unit/nest/budget.service.calc.test.ts": 1,
|
||||
"tests/unit/nest/budget.service.db.test.ts": 1,
|
||||
"tests/unit/nest/budget.service.test.ts": 1,
|
||||
"tests/unit/nest/calendar.service.test.ts": 1,
|
||||
"tests/unit/nest/channel-override.test.ts": 1,
|
||||
"tests/unit/nest/collab.service.test.ts": 1,
|
||||
"tests/unit/nest/collections.service.test.ts": 1,
|
||||
"tests/unit/nest/common/conflictUpdate.test.ts": 1,
|
||||
"tests/unit/nest/database/database-lifecycle.service.test.ts": 2,
|
||||
"tests/unit/nest/dawarich-sync.service.test.ts": 1,
|
||||
"tests/unit/nest/day-notes.service.test.ts": 1,
|
||||
"tests/unit/nest/day-removal.service.test.ts": 1,
|
||||
"tests/unit/nest/days.service.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync.service.test.ts": 1,
|
||||
"tests/unit/nest/files.service.test.ts": 1,
|
||||
"tests/unit/nest/google-quota.service.test.ts": 1,
|
||||
"tests/unit/nest/immich.service.test.ts": 1,
|
||||
"tests/unit/nest/journey-book.service.test.ts": 1,
|
||||
"tests/unit/nest/journey-domain.service.test.ts": 1,
|
||||
"tests/unit/nest/journey-share.service.test.ts": 1,
|
||||
"tests/unit/nest/journey-stats.service.test.ts": 1,
|
||||
"tests/unit/nest/mailer.service.test.ts": 1,
|
||||
"tests/unit/nest/maps.amap.test.ts": 1,
|
||||
"tests/unit/nest/maps.curated-images.test.ts": 1,
|
||||
"tests/unit/nest/maps.google-quota.test.ts": 1,
|
||||
"tests/unit/nest/maps.identity.test.ts": 1,
|
||||
"tests/unit/nest/maps.service.test.ts": 1,
|
||||
"tests/unit/nest/mcp-shared.service.test.ts": 1,
|
||||
"tests/unit/nest/mcp-transport.service.test.ts": 1,
|
||||
"tests/unit/nest/memories-access.service.test.ts": 1,
|
||||
"tests/unit/nest/notification-preferences.service.test.ts": 1,
|
||||
"tests/unit/nest/notification-transports.test.ts": 1,
|
||||
"tests/unit/nest/notifications.inapp-prefs.test.ts": 1,
|
||||
"tests/unit/nest/notifications.service.test.ts": 1,
|
||||
"tests/unit/nest/notifications/push-subscriptions.service.test.ts": 1,
|
||||
"tests/unit/nest/notifications/vapid-keys.service.test.ts": 1,
|
||||
"tests/unit/nest/notifications/web-push-dispatch.test.ts": 1,
|
||||
"tests/unit/nest/notifications/web-push.service.test.ts": 1,
|
||||
"tests/unit/nest/oauth.service.test.ts": 1,
|
||||
"tests/unit/nest/oidc.service.test.ts": 1,
|
||||
"tests/unit/nest/packing.service.test.ts": 1,
|
||||
"tests/unit/nest/passkey.service.test.ts": 1,
|
||||
"tests/unit/nest/permissions.service.test.ts": 1,
|
||||
"tests/unit/nest/photo-resolver.service.test.ts": 1,
|
||||
"tests/unit/nest/photos/trekPhotoMedia.test.ts": 1,
|
||||
"tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 1,
|
||||
"tests/unit/nest/place-regions-repair.test.ts": 1,
|
||||
"tests/unit/nest/places.controller.test.ts": 1,
|
||||
"tests/unit/nest/places.directions.test.ts": 1,
|
||||
"tests/unit/nest/places.helpers.test.ts": 1,
|
||||
"tests/unit/nest/places.service.test.ts": 1,
|
||||
"tests/unit/nest/platform.controller.test.ts": 1,
|
||||
"tests/unit/nest/public-api-scopes.test.ts": 1,
|
||||
"tests/unit/nest/registration-invites.service.test.ts": 1,
|
||||
"tests/unit/nest/reminder-jobs.service.test.ts": 1,
|
||||
"tests/unit/nest/reservations.service.test.ts": 1,
|
||||
"tests/unit/nest/route-inventory.test.ts": 1,
|
||||
"tests/unit/nest/settings.service.test.ts": 1,
|
||||
"tests/unit/nest/share.service.test.ts": 1,
|
||||
"tests/unit/nest/storage/storage-admin.service.test.ts": 1,
|
||||
"tests/unit/nest/storage/storage-jobs.service.test.ts": 1,
|
||||
"tests/unit/nest/storage/storage-registry.service.test.ts": 1,
|
||||
"tests/unit/nest/storage/storage-stats.service.test.ts": 1,
|
||||
"tests/unit/nest/synology.service.test.ts": 1,
|
||||
"tests/unit/nest/todo.service.test.ts": 1,
|
||||
"tests/unit/nest/token.service.test.ts": 1,
|
||||
"tests/unit/nest/tours.controller.test.ts": 1,
|
||||
"tests/unit/nest/tours.gpx.atomic.test.ts": 1,
|
||||
"tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts": 1,
|
||||
"tests/unit/nest/tours.permissions.test.ts": 1,
|
||||
"tests/unit/nest/tours.service.permissions.test.ts": 1,
|
||||
"tests/unit/nest/tours.service.test.ts": 1,
|
||||
"tests/unit/nest/transit-itinerary.helpers.test.ts": 1,
|
||||
"tests/unit/nest/travel-stats.service.test.ts": 1,
|
||||
"tests/unit/nest/trip-invite.service.test.ts": 1,
|
||||
"tests/unit/nest/trip-members.service.test.ts": 1,
|
||||
"tests/unit/nest/trip-read-model.service.test.ts": 1,
|
||||
"tests/unit/nest/trips.service.test.ts": 1,
|
||||
"tests/unit/nest/unified-memories.service.test.ts": 1,
|
||||
"tests/unit/nest/user-cleanup.service.test.ts": 1,
|
||||
"tests/unit/nest/user-profile.service.test.ts": 1,
|
||||
"tests/unit/nest/vacay.service.test.ts": 1,
|
||||
"tests/unit/plugins/atlas-layers.controller.test.ts": 1,
|
||||
"tests/unit/plugins/day-schedule.controller.test.ts": 1,
|
||||
"tests/unit/plugins/day-tints.controller.test.ts": 1,
|
||||
"tests/unit/plugins/host-rpc.test.ts": 1,
|
||||
"tests/unit/plugins/map-layers.controller.test.ts": 1,
|
||||
"tests/unit/plugins/map-markers.controller.test.ts": 1,
|
||||
"tests/unit/plugins/operator-egress.test.ts": 1,
|
||||
"tests/unit/plugins/pdf-sections.controller.test.ts": 1,
|
||||
"tests/unit/plugins/place-details.controller.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-instance-settings.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-oauth.service.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-pois.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-retrust.controller.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-routes.controller.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-user-settings.test.ts": 1,
|
||||
"tests/unit/plugins/plugins-service.test.ts": 1,
|
||||
"tests/unit/plugins/settings-isolation.test.ts": 1,
|
||||
"tests/unit/plugins/trip-card-contributions.controller.test.ts": 1,
|
||||
"tests/unit/plugins/trip-warnings.controller.test.ts": 1,
|
||||
"tests/unit/plugins/view-contributions.controller.test.ts": 1,
|
||||
"tests/websocket/connection.test.ts": 1,
|
||||
"tests/websocket/wire-envelope.test.ts": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:test-mocks: module mocks of the config, the realtime transport and the
|
||||
* database modules in tests/ may only shrink.
|
||||
*
|
||||
* A `vi.mock` of one of those paths replaces a module every domain imports,
|
||||
* so a suite stays green for reasons its cases do not show (a factory that
|
||||
* happens to export the names the import order reaches first) and every
|
||||
* constructor or import change ripples through hundreds of factories. The
|
||||
* test infrastructure covers each of them instead:
|
||||
*
|
||||
* src/config the setup file fixes its values once
|
||||
* (tests/helpers/test-config.ts, overrideTestConfig)
|
||||
* src/nest/realtime/** a FakeRealtimeService through DI, or spyOnRealtime(app)
|
||||
* src/db/** a snapshot test db (tests/helpers/db-mock.ts) handed
|
||||
* to the code under test; the remaining `src/db/database`
|
||||
* mocks are the legacy this counts down
|
||||
*
|
||||
* Every `vi.mock(` / `vi.doMock(` whose path ends in one of those modules
|
||||
* counts, per file, against scripts/test-mocks-baseline.json; a file without
|
||||
* an entry may hold none (scripts/lib/count-ratchet.mjs has the rules).
|
||||
*
|
||||
* npm run lint:test-mocks check against the baseline (CI)
|
||||
* npm run lint:test-mocks -- --update lower the counts; never raises or adds an entry
|
||||
*/
|
||||
import { cliMain } from './lib/count-ratchet.mjs';
|
||||
|
||||
const MOCK_CALL = /\bvi\.(?:mock|doMock)\(\s*(['"`])([^'"`]+)\1/g;
|
||||
|
||||
/** A mocked path that names one of the held modules, with or without an extension. */
|
||||
const HELD_TARGET = /(?:^|\/)src\/(?:config|websocket|db(?:\/[^'"`]*)?|nest\/realtime(?:\/[^'"`]*)?)$/;
|
||||
|
||||
/** The module mocks of held targets in one file's text. */
|
||||
export function countHeldMocks(text) {
|
||||
let n = 0;
|
||||
for (const match of text.matchAll(MOCK_CALL)) {
|
||||
const target = match[2].replace(/\.(?:[cm]?[jt]s)$/, '');
|
||||
if (HELD_TARGET.test(target)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export const check = {
|
||||
name: 'test-mocks',
|
||||
script: 'lint:test-mocks',
|
||||
root: 'tests',
|
||||
baseline: 'scripts/test-mocks-baseline.json',
|
||||
unit: 'module mock(s) of config, realtime or db',
|
||||
advice:
|
||||
'Use the test config (overrideTestConfig), a FakeRealtimeService or spyOnRealtime(app), ' +
|
||||
'and a snapshot test db handed to the code under test instead of mocking the module.',
|
||||
include: (file) => /\.[cm]?[jt]s$/.test(file),
|
||||
count: (text) => countHeldMocks(text),
|
||||
};
|
||||
|
||||
cliMain(check, import.meta.url);
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"counts": {
|
||||
"tests/helpers/accommodations-service.ts": 8,
|
||||
"tests/helpers/budget-repos.ts": 1,
|
||||
"tests/helpers/fake-realtime.ts": 1,
|
||||
"tests/helpers/maps-service.ts": 1,
|
||||
"tests/helpers/notifications.ts": 10,
|
||||
"tests/helpers/place-import.ts": 1,
|
||||
"tests/helpers/plugin-host.ts": 45,
|
||||
"tests/helpers/sessions.ts": 1,
|
||||
"tests/helpers/share-repos.ts": 1,
|
||||
"tests/helpers/storage-fixture.ts": 1,
|
||||
"tests/helpers/test-addons.ts": 2,
|
||||
"tests/helpers/test-app.ts": 1,
|
||||
"tests/helpers/test-uow.ts": 3,
|
||||
"tests/integration/dayReorder.test.ts": 3,
|
||||
"tests/integration/mcp.test.ts": 2,
|
||||
"tests/integration/oauth-authorize.test.ts": 2,
|
||||
"tests/integration/oauth.test.ts": 2,
|
||||
"tests/integration/plugins/boot-registry-order.test.ts": 7,
|
||||
"tests/integration/plugins/plugin-runtime.task2-conversion.test.ts": 17,
|
||||
"tests/integration/plugins/plugin-scheduler-sweep.test.ts": 5,
|
||||
"tests/integration/plugins/registry.test.ts": 2,
|
||||
"tests/integration/share.test.ts": 2,
|
||||
"tests/integration/storage-restore-reload.test.ts": 2,
|
||||
"tests/integration/storage-stream-abort.test.ts": 3,
|
||||
"tests/unit/budget/costs.rpc.test.ts": 1,
|
||||
"tests/unit/db/connection.test.ts": 1,
|
||||
"tests/unit/db/repositories/DemoRepository.test.ts": 1,
|
||||
"tests/unit/db/repositories/MaintenanceRepository.test.ts": 2,
|
||||
"tests/unit/db/repositories/UserConnections.repository.test.ts": 3,
|
||||
"tests/unit/demo/demo-reset-sessions.test.ts": 3,
|
||||
"tests/unit/mcp/tools-airtrail.test.ts": 1,
|
||||
"tests/unit/mcp/tools-budget-advanced.test.ts": 4,
|
||||
"tests/unit/mcp/tools-categories.test.ts": 4,
|
||||
"tests/unit/mcp/tools-prompts.test.ts": 7,
|
||||
"tests/unit/mcp/tools-roadtrip.test.ts": 1,
|
||||
"tests/unit/mcp/tools-transit.test.ts": 1,
|
||||
"tests/unit/nest-rpc/plugin-guards.test.ts": 1,
|
||||
"tests/unit/nest-rpc/rpc-kit/registry.service.test.ts": 3,
|
||||
"tests/unit/nest/accommodations.event-parity.test.ts": 1,
|
||||
"tests/unit/nest/accommodations.service.test.ts": 1,
|
||||
"tests/unit/nest/addons.service.test.ts": 2,
|
||||
"tests/unit/nest/admin.service.test.ts": 11,
|
||||
"tests/unit/nest/admin.version-notification.test.ts": 11,
|
||||
"tests/unit/nest/airports/airports.service.test.ts": 7,
|
||||
"tests/unit/nest/airtrail.service.test.ts": 3,
|
||||
"tests/unit/nest/assignments.service.test.ts": 6,
|
||||
"tests/unit/nest/atlas.service.test.ts": 2,
|
||||
"tests/unit/nest/audit.service.test.ts": 1,
|
||||
"tests/unit/nest/auth-core/auth-guard.test.ts": 3,
|
||||
"tests/unit/nest/auth.controller.test.ts": 1,
|
||||
"tests/unit/nest/auth.service.test.ts": 9,
|
||||
"tests/unit/nest/backup.controller.test.ts": 1,
|
||||
"tests/unit/nest/booking-import/booking-import.service.test.ts": 3,
|
||||
"tests/unit/nest/booking-import/import-jobs.service.test.ts": 1,
|
||||
"tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts": 1,
|
||||
"tests/unit/nest/budget.service.calc.test.ts": 1,
|
||||
"tests/unit/nest/budget.service.db.test.ts": 6,
|
||||
"tests/unit/nest/budget.service.test.ts": 1,
|
||||
"tests/unit/nest/calendar.service.test.ts": 6,
|
||||
"tests/unit/nest/categories.service.test.ts": 2,
|
||||
"tests/unit/nest/channel-override.test.ts": 2,
|
||||
"tests/unit/nest/charging.test.ts": 6,
|
||||
"tests/unit/nest/collab.service.test.ts": 6,
|
||||
"tests/unit/nest/collections.service.test.ts": 3,
|
||||
"tests/unit/nest/common/conflictUpdate.test.ts": 9,
|
||||
"tests/unit/nest/cron-registrar.service.test.ts": 18,
|
||||
"tests/unit/nest/dawarich-suggestions.service.test.ts": 1,
|
||||
"tests/unit/nest/dawarich-sync.service.test.ts": 1,
|
||||
"tests/unit/nest/dawarich-tracks.service.test.ts": 1,
|
||||
"tests/unit/nest/dawarich.service.test.ts": 1,
|
||||
"tests/unit/nest/day-boundaries.test.ts": 1,
|
||||
"tests/unit/nest/demo.service.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 1,
|
||||
"tests/unit/nest/doc-sync/doc-sync.job.test.ts": 3,
|
||||
"tests/unit/nest/doc-sync/doc-sync.service.test.ts": 3,
|
||||
"tests/unit/nest/features.controller.test.ts": 2,
|
||||
"tests/unit/nest/feeds.service.test.ts": 1,
|
||||
"tests/unit/nest/files.service.test.ts": 7,
|
||||
"tests/unit/nest/google-quota.service.test.ts": 1,
|
||||
"tests/unit/nest/google-route.test.ts": 1,
|
||||
"tests/unit/nest/google-transit.provider.test.ts": 7,
|
||||
"tests/unit/nest/immich.service.test.ts": 1,
|
||||
"tests/unit/nest/integrations/airtrailImport.test.ts": 5,
|
||||
"tests/unit/nest/integrations/airtrailTestConnection.test.ts": 3,
|
||||
"tests/unit/nest/integrations/airtrailWriteback.test.ts": 3,
|
||||
"tests/unit/nest/integrations/airtrailWriteGate.test.ts": 2,
|
||||
"tests/unit/nest/journey-book.service.test.ts": 3,
|
||||
"tests/unit/nest/journey-domain.service.test.ts": 2,
|
||||
"tests/unit/nest/journey-photo-capture.service.test.ts": 3,
|
||||
"tests/unit/nest/journey-share.service.test.ts": 4,
|
||||
"tests/unit/nest/journey-stats.service.test.ts": 2,
|
||||
"tests/unit/nest/llm-parse/llm-parse.service.test.ts": 1,
|
||||
"tests/unit/nest/mailer.service.test.ts": 1,
|
||||
"tests/unit/nest/mcp-shared.service.test.ts": 2,
|
||||
"tests/unit/nest/mcp-transport.service.test.ts": 1,
|
||||
"tests/unit/nest/memories-access.service.test.ts": 1,
|
||||
"tests/unit/nest/memories.service.test.ts": 1,
|
||||
"tests/unit/nest/memories/journey-thumbs.job.test.ts": 2,
|
||||
"tests/unit/nest/memories/trek-photo-cache.job.test.ts": 1,
|
||||
"tests/unit/nest/notification-preferences.service.test.ts": 4,
|
||||
"tests/unit/nest/notification-transports.test.ts": 3,
|
||||
"tests/unit/nest/notifications/storage-health-notifier.service.test.ts": 2,
|
||||
"tests/unit/nest/notifications/vapid-keys.service.test.ts": 2,
|
||||
"tests/unit/nest/notifications/web-push.service.test.ts": 1,
|
||||
"tests/unit/nest/oauth.controller.test.ts": 1,
|
||||
"tests/unit/nest/oauth.service.test.ts": 4,
|
||||
"tests/unit/nest/oidc.service.test.ts": 13,
|
||||
"tests/unit/nest/packing-writes.service.test.ts": 2,
|
||||
"tests/unit/nest/packing.controller.test.ts": 1,
|
||||
"tests/unit/nest/packing.service.test.ts": 1,
|
||||
"tests/unit/nest/passkey.service.test.ts": 10,
|
||||
"tests/unit/nest/permissions.service.test.ts": 2,
|
||||
"tests/unit/nest/photo-capture-backfill.exif.test.ts": 1,
|
||||
"tests/unit/nest/photo-capture-backfill.service.test.ts": 5,
|
||||
"tests/unit/nest/photo-resolver.service.test.ts": 2,
|
||||
"tests/unit/nest/photos/trekPhotoMedia.test.ts": 1,
|
||||
"tests/unit/nest/place-enrichment.brand.test.ts": 1,
|
||||
"tests/unit/nest/place-enrichment.controller.test.ts": 2,
|
||||
"tests/unit/nest/place-enrichment.description.test.ts": 1,
|
||||
"tests/unit/nest/place-enrichment.service.test.ts": 1,
|
||||
"tests/unit/nest/place-photos/place-photo-cache.job.test.ts": 2,
|
||||
"tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 2,
|
||||
"tests/unit/nest/place-regions-repair.test.ts": 1,
|
||||
"tests/unit/nest/place-shadow.service.test.ts": 1,
|
||||
"tests/unit/nest/places.directions.test.ts": 6,
|
||||
"tests/unit/nest/places.service.test.ts": 6,
|
||||
"tests/unit/nest/process-state-ports.test.ts": 9,
|
||||
"tests/unit/nest/public-api-scopes.test.ts": 4,
|
||||
"tests/unit/nest/public-api.service.test.ts": 1,
|
||||
"tests/unit/nest/public-stats.controller.test.ts": 2,
|
||||
"tests/unit/nest/query-helpers.service.test.ts": 1,
|
||||
"tests/unit/nest/rate-limit.service.test.ts": 6,
|
||||
"tests/unit/nest/realtime.gateway.test.ts": 16,
|
||||
"tests/unit/nest/receipt-scan.controller.test.ts": 1,
|
||||
"tests/unit/nest/registration-invites.service.test.ts": 1,
|
||||
"tests/unit/nest/reminder-jobs.service.test.ts": 2,
|
||||
"tests/unit/nest/reservations.service.test.ts": 2,
|
||||
"tests/unit/nest/roadtrip-plan.service.test.ts": 5,
|
||||
"tests/unit/nest/roadtrip-planning.test.ts": 2,
|
||||
"tests/unit/nest/roadtrip-router.test.ts": 1,
|
||||
"tests/unit/nest/roadtrip-search.test.ts": 3,
|
||||
"tests/unit/nest/roadtrip.service.test.ts": 1,
|
||||
"tests/unit/nest/route-usage.service.test.ts": 1,
|
||||
"tests/unit/nest/school-holidays.service.test.ts": 2,
|
||||
"tests/unit/nest/sessions/sessions.service.test.ts": 1,
|
||||
"tests/unit/nest/settings.service.test.ts": 1,
|
||||
"tests/unit/nest/share.service.test.ts": 3,
|
||||
"tests/unit/nest/sqlite-database-backup.test.ts": 2,
|
||||
"tests/unit/nest/storage/storage-admin.service.test.ts": 5,
|
||||
"tests/unit/nest/storage/storage-jobs.service.test.ts": 15,
|
||||
"tests/unit/nest/storage/storage-registry.service.test.ts": 3,
|
||||
"tests/unit/nest/storage/storage-stats.service.test.ts": 3,
|
||||
"tests/unit/nest/storage/storage.service.test.ts": 5,
|
||||
"tests/unit/nest/synology.service.test.ts": 1,
|
||||
"tests/unit/nest/system-notices.branch-coverage.test.ts": 1,
|
||||
"tests/unit/nest/system-notices.service.test.ts": 1,
|
||||
"tests/unit/nest/tags.service.test.ts": 2,
|
||||
"tests/unit/nest/thumbnail.service.test.ts": 1,
|
||||
"tests/unit/nest/todo.service.test.ts": 5,
|
||||
"tests/unit/nest/token.service.test.ts": 1,
|
||||
"tests/unit/nest/tours.gpx.atomic.test.ts": 7,
|
||||
"tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts": 1,
|
||||
"tests/unit/nest/tours.service.permissions.test.ts": 1,
|
||||
"tests/unit/nest/tours.service.test.ts": 1,
|
||||
"tests/unit/nest/transit.service.test.ts": 2,
|
||||
"tests/unit/nest/travel-stats.service.test.ts": 1,
|
||||
"tests/unit/nest/trek-photo-cache.service.test.ts": 2,
|
||||
"tests/unit/nest/trek-photo-registration.service.test.ts": 1,
|
||||
"tests/unit/nest/trip-invite.service.test.ts": 4,
|
||||
"tests/unit/nest/trip-members.service.test.ts": 6,
|
||||
"tests/unit/nest/trip-membership/trip-membership.service.test.ts": 1,
|
||||
"tests/unit/nest/trip-read-model.service.test.ts": 31,
|
||||
"tests/unit/nest/trips.service.test.ts": 38,
|
||||
"tests/unit/nest/unified-memories.service.test.ts": 3,
|
||||
"tests/unit/nest/unsplash/unsplash.service.test.ts": 1,
|
||||
"tests/unit/nest/user-cleanup.service.test.ts": 8,
|
||||
"tests/unit/nest/user-profile.service.test.ts": 1,
|
||||
"tests/unit/nest/vacay.service.test.ts": 2,
|
||||
"tests/unit/nest/weather/weather-cache-lifecycle.test.ts": 1,
|
||||
"tests/unit/nest/webauthn-config.service.test.ts": 1,
|
||||
"tests/unit/packing/packing.rpc.test.ts": 1,
|
||||
"tests/unit/plugins/addon-gates.rpc.test.ts": 1,
|
||||
"tests/unit/plugins/day-schedule.controller.test.ts": 1,
|
||||
"tests/unit/plugins/day-tints.controller.test.ts": 1,
|
||||
"tests/unit/plugins/map-layers.controller.test.ts": 1,
|
||||
"tests/unit/plugins/map-markers.controller.test.ts": 1,
|
||||
"tests/unit/plugins/operator-egress.test.ts": 1,
|
||||
"tests/unit/plugins/pdf-sections.controller.test.ts": 1,
|
||||
"tests/unit/plugins/place-details.controller.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-instance-settings.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-mcp-tools.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-oauth.service.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-pois.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-routes.controller.test.ts": 1,
|
||||
"tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts": 3,
|
||||
"tests/unit/plugins/plugin-user-settings.test.ts": 2,
|
||||
"tests/unit/plugins/plugins-service.test.ts": 1,
|
||||
"tests/unit/plugins/request-context.test.ts": 1,
|
||||
"tests/unit/plugins/rpc-coverage.test.ts": 1,
|
||||
"tests/unit/plugins/settings-isolation.test.ts": 3,
|
||||
"tests/unit/plugins/trip-card-contributions.controller.test.ts": 1,
|
||||
"tests/unit/plugins/trip-warnings.controller.test.ts": 1,
|
||||
"tests/unit/plugins/view-contributions.controller.test.ts": 1,
|
||||
"tests/websocket/connection.test.ts": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:test-new-service: hand-built services and repositories in tests/ may
|
||||
* only shrink.
|
||||
*
|
||||
* `new XService(a, b, c, ...)` in a test pins the constructor's parameter
|
||||
* order: every dependency a service gains is a change to every suite that
|
||||
* builds it (and to every suite that builds one of its consumers), which is
|
||||
* why the harness files are among the most churned in the repository. A suite
|
||||
* takes its subject from a Nest testing module instead (tests/helpers/
|
||||
* test-module.ts: createTestModule with overrides), and a booted app hands
|
||||
* the rest out through `app.get(...)`.
|
||||
*
|
||||
* Every `new <Name>Service(` and `new <Name>Repository(` under tests/ counts,
|
||||
* per file, against scripts/test-new-service-baseline.json; a file without an
|
||||
* entry may hold none (scripts/lib/count-ratchet.mjs has the rules). The
|
||||
* allowlist is derived, not kept by hand: a class whose constructor takes no
|
||||
* parameters (no constructor of its own, or an empty one, down its chain of
|
||||
* `extends` inside src/ and tests/helpers/) has no wiring to pin, so building
|
||||
* it with `new` does not count. RealtimeService and FakeRealtimeService are
|
||||
* the common case.
|
||||
*
|
||||
* npm run lint:test-new-service check against the baseline (CI)
|
||||
* npm run lint:test-new-service -- --update lower the counts; never raises or adds an entry
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { cliMain } from './lib/count-ratchet.mjs';
|
||||
|
||||
const NEW_CALL = /\bnew\s+([A-Z][A-Za-z0-9_]*(?:Service|Repository))\s*[(<]/g;
|
||||
const CLASS_DECL = /\bclass\s+([A-Za-z_$][\w$]*)(?:\s*<[^{]*?>)?(?:\s+extends\s+([A-Za-z_$][\w$.]*))?[^{]*\{/g;
|
||||
|
||||
/** The trees whose classes the allowlist is derived from, relative to the server root. */
|
||||
const CLASS_ROOTS = ['src', 'tests/helpers'];
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (/\.[cm]?ts$/.test(name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The index just past the bracket that closes the one at `open`, skipping strings and comments. */
|
||||
function closeOf(text, open) {
|
||||
const pairs = { '(': ')', '{': '}', '[': ']' };
|
||||
const stack = [];
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '/' && text[i + 1] === '/') {
|
||||
i = text.indexOf('\n', i);
|
||||
if (i < 0) return text.length;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && text[i + 1] === '*') {
|
||||
i = text.indexOf('*/', i + 2) + 1;
|
||||
if (i <= 0) return text.length;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++;
|
||||
continue;
|
||||
}
|
||||
if (pairs[c]) stack.push(pairs[c]);
|
||||
else if (c === stack[stack.length - 1]) {
|
||||
stack.pop();
|
||||
if (!stack.length) return i + 1;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class declared in `text`: its parent, and whether its own constructor
|
||||
* takes parameters (`null` when it declares none).
|
||||
*/
|
||||
export function parseClasses(text) {
|
||||
const classes = [];
|
||||
for (const match of text.matchAll(CLASS_DECL)) {
|
||||
const bodyOpen = match.index + match[0].length - 1;
|
||||
const body = text.slice(bodyOpen, closeOf(text, bodyOpen));
|
||||
const ctor = /\bconstructor\s*\(/.exec(body);
|
||||
let ctorHasParams = null;
|
||||
if (ctor) {
|
||||
const open = ctor.index + ctor[0].length - 1;
|
||||
const params = body
|
||||
.slice(open + 1, closeOf(body, open) - 1)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '')
|
||||
.trim();
|
||||
ctorHasParams = params.length > 0;
|
||||
}
|
||||
classes.push({ name: match[1], parent: match[2] ?? null, ctorHasParams });
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The names whose constructor takes no parameters, resolved down the extends
|
||||
* chain. A parent declared outside the scanned trees (a library class) is
|
||||
* taken to need arguments, so a subclass without a constructor of its own counts.
|
||||
*/
|
||||
export function zeroArgClasses(classes) {
|
||||
const byName = new Map();
|
||||
for (const c of classes) if (!byName.has(c.name)) byName.set(c.name, c);
|
||||
const memo = new Map();
|
||||
const resolve = (name, seen = new Set()) => {
|
||||
if (memo.has(name)) return memo.get(name);
|
||||
const c = byName.get(name);
|
||||
let zero;
|
||||
if (!c || seen.has(name)) zero = false;
|
||||
else if (c.ctorHasParams !== null) zero = !c.ctorHasParams;
|
||||
else zero = c.parent === null ? true : resolve(c.parent, new Set([...seen, name]));
|
||||
memo.set(name, zero);
|
||||
return zero;
|
||||
};
|
||||
return new Set([...byName.keys()].filter((name) => resolve(name)));
|
||||
}
|
||||
|
||||
/** The hand-built services and repositories in one file's text, `allowed` left out. */
|
||||
export function countNewServices(text, allowed = new Set()) {
|
||||
let n = 0;
|
||||
for (const match of text.matchAll(NEW_CALL)) if (!allowed.has(match[1])) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
export const check = {
|
||||
name: 'test-new-service',
|
||||
script: 'lint:test-new-service',
|
||||
root: 'tests',
|
||||
baseline: 'scripts/test-new-service-baseline.json',
|
||||
unit: 'hand-built service(s) or repository(ies)',
|
||||
advice:
|
||||
'Take the subject from createTestModule (tests/helpers/test-module.ts) with overrides for its ' +
|
||||
'collaborators, or from a booted app with app.get(), instead of calling its constructor.',
|
||||
prepare: (serverDir) => {
|
||||
const classes = [];
|
||||
for (const root of CLASS_ROOTS) {
|
||||
const dir = join(serverDir, root);
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const path of walk(dir)) classes.push(...parseClasses(readFileSync(path, 'utf8')));
|
||||
}
|
||||
return zeroArgClasses(classes);
|
||||
},
|
||||
include: (file) => /\.[cm]?[jt]s$/.test(file),
|
||||
count: (text, _file, allowed) => countNewServices(text, allowed),
|
||||
};
|
||||
|
||||
cliMain(check, import.meta.url);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tests/unit/nest/atlas.service.test.ts": 2193,
|
||||
"tests/unit/nest/budget.service.db.test.ts": 2220,
|
||||
"tests/unit/nest/doc-sync/doc-sync.service.test.ts": 2511,
|
||||
"tests/unit/nest/journey-domain.service.test.ts": 3876,
|
||||
"tests/unit/nest/maps.service.test.ts": 3751,
|
||||
"tests/unit/nest/places.service.test.ts": 2561,
|
||||
"tests/unit/nest/trips.service.test.ts": 2404
|
||||
}
|
||||
@@ -100,7 +100,7 @@ over an injected `AddonsService`.
|
||||
took the same three ids in different orders. `local` is not a provider; both
|
||||
dispatch sites branch on it.
|
||||
- `realtime/`: `/ws` as a Nest gateway. `ws-state.ts` is module state on purpose
|
||||
(test harnesses build `RealtimeService` outside the container). The socket id
|
||||
(a `RealtimeService` built outside the container delivers to the same rooms). The socket id
|
||||
stays a monotonic integer, because `broadcast` excludes the originator with
|
||||
`Number(excludeSid)`. `trek-ws.adapter.ts` builds the ws server itself so
|
||||
`verifyClient` keeps the origin check, and attaches the per-socket `error`
|
||||
@@ -196,9 +196,13 @@ events whichever surface made the change.
|
||||
Every module ships two kinds of tests.
|
||||
|
||||
1. **Unit**: `tests/unit/nest/<domain>.controller.test.ts` and
|
||||
`<domain>.service.test.ts`. Build the class directly with its collaborators
|
||||
(no `overrideProvider`); assert status codes, the exact `{ error }` bodies and
|
||||
how inputs are forwarded. Parity assertions live here; there is no separate
|
||||
`<domain>.service.test.ts`. A controller test builds the controller over stub
|
||||
services; a service test takes the service out of its module with
|
||||
`createTestModule` (`tests/helpers/test-module.ts`) and replaces the
|
||||
collaborators it controls through `overrides`, instead of calling the
|
||||
constructor with positional arguments (`lint:test-new-service` holds the
|
||||
hand-built rest). Assert status codes, the exact `{ error }` bodies and how
|
||||
inputs are forwarded. Parity assertions live here; there is no separate
|
||||
parity directory. See `tests/unit/nest/weather.controller.test.ts`.
|
||||
2. **e2e**: `tests/e2e/<domain>.e2e.test.ts` boots the module against a temp
|
||||
SQLite through `tests/e2e/harness.ts` (`createTempDb`, `seedUser`,
|
||||
@@ -209,6 +213,14 @@ Every module ships two kinds of tests.
|
||||
Seed and read rows through the ORM factories in `tests/helpers/factories/`;
|
||||
raw `.prepare()` fixtures are legacy that `lint:test-sql` only lets shrink.
|
||||
|
||||
The shared modules are not mocked by path (`lint:test-mocks` counts what is
|
||||
left): `src/config` answers the fixed values in `tests/helpers/test-config.ts`
|
||||
in every suite (`overrideTestConfig` for a case that needs others), broadcasts
|
||||
are recorded on a `FakeRealtimeService` (`tests/helpers/fake-realtime.ts`)
|
||||
handed in through DI or put over a booted app with `spyOnRealtime(app)`, and
|
||||
suites that drive a cross-domain surface (the MCP tools, the plugin RPC host)
|
||||
take AppModule itself from `bootTestApp` (`tests/helpers/test-app.ts`).
|
||||
|
||||
`tests/` is outside the build's `include`, so a constructor that grows a
|
||||
parameter does not break a hand-wired `new` in a test at build time;
|
||||
`npm run typecheck:tests` is the gate that catches it.
|
||||
|
||||
@@ -22,12 +22,18 @@ interface ExecError {
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function serverRoot(files: Record<string, string>, baseline: unknown = {}): string {
|
||||
function serverRoot(files: Record<string, string>, baseline: unknown = {}, testBaseline: unknown = {}): string {
|
||||
// null writes no baseline file at all.
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'trek-size-lint-'));
|
||||
roots.push(dir);
|
||||
mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
||||
// The tests scope needs a tree with a file in it; cases about it add their own.
|
||||
mkdirSync(path.join(dir, 'tests'), { recursive: true });
|
||||
writeFileSync(path.join(dir, 'tests/setup.ts'), lines(1));
|
||||
if (testBaseline !== null) {
|
||||
writeFileSync(path.join(dir, 'scripts/test-size-baseline.json'), JSON.stringify(testBaseline));
|
||||
}
|
||||
for (const [file, text] of Object.entries(files)) {
|
||||
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
||||
writeFileSync(path.join(dir, file), text);
|
||||
@@ -146,6 +152,43 @@ describe('size-lint.mjs', () => {
|
||||
expect(out).toContain('src/ does not exist');
|
||||
});
|
||||
|
||||
it('SIZE-011: holds files under tests/ to 2000 lines, test suites included, against their own baseline', () => {
|
||||
const ok = run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2000) }));
|
||||
expect(ok.status).toBe(0);
|
||||
expect(ok.out).toContain('size (tests): 2 file(s), 0 over 2000 lines');
|
||||
|
||||
const grown = run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2001) }));
|
||||
expect(grown.status).toBe(1);
|
||||
expect(grown.out).toContain('FAIL tests/unit/a.test.ts: 2001 lines, the limit is 2000.');
|
||||
|
||||
const held = { 'tests/unit/a.test.ts': 2500 };
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2500) }, {}, held)).status).toBe(0);
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2501) }, {}, held)).status).toBe(1);
|
||||
// A test file under src/ is still not part of the source scope.
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1), 'src/a.test.ts': lines(3000) })).status).toBe(0);
|
||||
});
|
||||
|
||||
it('SIZE-012: the tests baseline lowers with --update and fails closed when it is missing', () => {
|
||||
const dir = serverRoot(
|
||||
{ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2100) },
|
||||
{},
|
||||
{ 'tests/unit/a.test.ts': 2400 },
|
||||
);
|
||||
const stale = run(dir);
|
||||
expect(stale.status).toBe(1);
|
||||
expect(stale.out).toContain(
|
||||
'FAIL tests/unit/a.test.ts is held at 2400 in scripts/test-size-baseline.json, but it has 2100 lines now.',
|
||||
);
|
||||
expect(run(dir, '--update').status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(path.join(dir, 'scripts/test-size-baseline.json'), 'utf8'))).toEqual({
|
||||
'tests/unit/a.test.ts': 2100,
|
||||
});
|
||||
|
||||
const missing = run(serverRoot({ 'src/a.ts': lines(1) }, {}, null));
|
||||
expect(missing.status).toBe(1);
|
||||
expect(missing.out).toContain('test-size-baseline.json cannot be read');
|
||||
});
|
||||
|
||||
it('SIZE-009: --update only lowers, drops files back under the limit, and never adds one', () => {
|
||||
const dir = serverRoot(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* scripts/test-mocks-ratchet.mjs (lint:test-mocks) and
|
||||
* scripts/test-new-service-ratchet.mjs (lint:test-new-service) on the shared
|
||||
* rules in scripts/lib/count-ratchet.mjs: the counters, the real scripts
|
||||
* against throwaway server roots (--dir), and once on this repository, so the
|
||||
* committed baselines are checked by the unit suite as well.
|
||||
*/
|
||||
import { countHeldMocks } from '../../../scripts/test-mocks-ratchet.mjs';
|
||||
import { countNewServices, parseClasses, zeroArgClasses } from '../../../scripts/test-new-service-ratchet.mjs';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const SCRIPTS = path.join(__dirname, '../../../scripts');
|
||||
const SERVER_ROOT = path.join(__dirname, '../../..');
|
||||
|
||||
// Spelled in pieces so this file does not count against the checks it tests.
|
||||
const MOCK = ['vi', '.mock('].join('');
|
||||
const DO_MOCK = ['vi', '.doMock('].join('');
|
||||
const NEW = ['ne', 'w '].join('');
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function serverRoot(files: Record<string, string>, baselineName: string, baseline: unknown = { counts: {} }): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'trek-test-ratchet-'));
|
||||
roots.push(dir);
|
||||
for (const sub of ['src', 'tests', 'scripts']) mkdirSync(path.join(dir, sub), { recursive: true });
|
||||
for (const [file, text] of Object.entries(files)) {
|
||||
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
||||
writeFileSync(path.join(dir, file), text);
|
||||
}
|
||||
if (baseline !== null) writeFileSync(path.join(dir, 'scripts', baselineName), JSON.stringify(baseline));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(script: string, dir: string | null, ...args: string[]): { status: number | null; out: string } {
|
||||
const dirArgs = dir ? [`--dir=${dir}`] : [];
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [path.join(SCRIPTS, script), ...dirArgs, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { status: 0, out: stdout };
|
||||
} catch (err) {
|
||||
const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string };
|
||||
return { status, out: `${stdout}${stderr}` };
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('lint:test-mocks', () => {
|
||||
it('TEST-RATCHET-001: counts mocks of the config, realtime and db modules in any spelling', () => {
|
||||
const text = [
|
||||
`${MOCK}'../../src/config', () => ({}));`,
|
||||
`${MOCK}"../../../src/websocket", () => ({}));`,
|
||||
`${MOCK}'../../src/db/database', async () => ({}));`,
|
||||
`${DO_MOCK}'../../src/db/orm.ts', () => ({}));`,
|
||||
`${MOCK}'../../../src/nest/realtime/ws-state', () => ({}));`,
|
||||
].join('\n');
|
||||
expect(countHeldMocks(text)).toBe(5);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-002: leaves other module mocks alone, including look-alike paths', () => {
|
||||
const text = [
|
||||
`${MOCK}'../../src/nest/audit/audit-log.logger', () => ({}));`,
|
||||
`${MOCK}'../../src/app-config', () => ({}));`,
|
||||
`${MOCK}'../../src/configuration', () => ({}));`,
|
||||
`${MOCK}'nodemailer', () => ({}));`,
|
||||
`${MOCK}'../../src/utils/db-helpers', () => ({}));`,
|
||||
].join('\n');
|
||||
expect(countHeldMocks(text)).toBe(0);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-003: fails a new mock, passes at the baseline and fails a stale entry until --update', () => {
|
||||
const mock = `${MOCK}'../../src/config', () => ({}));\n`;
|
||||
const grown = run(
|
||||
'test-mocks-ratchet.mjs',
|
||||
serverRoot({ 'tests/unit/a.test.ts': mock }, 'test-mocks-baseline.json'),
|
||||
);
|
||||
expect(grown.status).toBe(1);
|
||||
expect(grown.out).toContain('FAIL tests/unit/a.test.ts: 1 module mock(s) of config, realtime or db');
|
||||
|
||||
const held = { counts: { 'tests/unit/a.test.ts': 1 } };
|
||||
expect(
|
||||
run('test-mocks-ratchet.mjs', serverRoot({ 'tests/unit/a.test.ts': mock }, 'test-mocks-baseline.json', held))
|
||||
.status,
|
||||
).toBe(0);
|
||||
|
||||
const dir = serverRoot({ 'tests/unit/a.test.ts': 'nothing mocked here\n' }, 'test-mocks-baseline.json', held);
|
||||
const stale = run('test-mocks-ratchet.mjs', dir);
|
||||
expect(stale.status).toBe(1);
|
||||
expect(stale.out).toContain('npm run lint:test-mocks -- --update');
|
||||
expect(run('test-mocks-ratchet.mjs', dir, '--update').status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(path.join(dir, 'scripts/test-mocks-baseline.json'), 'utf8'))).toEqual({
|
||||
counts: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-004: the committed baseline holds this repository', () => {
|
||||
const { status, out } = run('test-mocks-ratchet.mjs', null);
|
||||
expect(out).toContain('held at their baseline');
|
||||
expect(status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lint:test-new-service', () => {
|
||||
it('TEST-RATCHET-010: reads each class, its parent and whether its constructor takes parameters', () => {
|
||||
const text = [
|
||||
'export class A { constructor(private readonly b: B) {} }',
|
||||
'class C extends A {}',
|
||||
'export class D { constructor(/* nothing */) {} }',
|
||||
'export class E { run() { return 1; } }',
|
||||
'export class F<T> extends E { constructor() { super(); } }',
|
||||
].join('\n');
|
||||
expect(parseClasses(text)).toEqual([
|
||||
{ name: 'A', parent: null, ctorHasParams: true },
|
||||
{ name: 'C', parent: 'A', ctorHasParams: null },
|
||||
{ name: 'D', parent: null, ctorHasParams: false },
|
||||
{ name: 'E', parent: null, ctorHasParams: null },
|
||||
{ name: 'F', parent: 'E', ctorHasParams: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-011: a class needs no wiring when nothing down its extends chain takes parameters', () => {
|
||||
const allowed = zeroArgClasses([
|
||||
{ name: 'PlainService', parent: null, ctorHasParams: null },
|
||||
{ name: 'WiredService', parent: null, ctorHasParams: true },
|
||||
{ name: 'EmptyCtorService', parent: 'WiredService', ctorHasParams: false },
|
||||
{ name: 'InheritsWiringService', parent: 'WiredService', ctorHasParams: null },
|
||||
{ name: 'InheritsPlainService', parent: 'PlainService', ctorHasParams: null },
|
||||
{ name: 'LibraryRepository', parent: 'EntityRepository', ctorHasParams: null },
|
||||
]);
|
||||
expect([...allowed].sort()).toEqual(['EmptyCtorService', 'InheritsPlainService', 'PlainService']);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-012: counts hand-built services and repositories, not allowed or other classes', () => {
|
||||
const text = [
|
||||
`const a = ${NEW}TodoService(permissions, realtime);`,
|
||||
`const b = ${NEW}TripsRepository(em);`,
|
||||
`const c = ${NEW}Map<string, number>();`,
|
||||
`const d = ${NEW}RealtimeService();`,
|
||||
`const e = ${NEW}Store<number>(1);`,
|
||||
`const f = ${NEW}CacheService<string>(store);`,
|
||||
].join('\n');
|
||||
expect(countNewServices(text, new Set(['RealtimeService']))).toBe(3);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-013: derives the allowlist from src/ and tests/helpers/ when it runs', () => {
|
||||
const files = {
|
||||
'src/plain.service.ts': 'export class PlainService { ping() {} }',
|
||||
'src/wired.service.ts': 'export class WiredService { constructor(private readonly p: PlainService) {} }',
|
||||
'tests/helpers/fake.ts':
|
||||
'export class FakeWiredService extends WiredService { constructor() { super(null as never); } }',
|
||||
'tests/unit/a.test.ts': [
|
||||
`${NEW}PlainService();`,
|
||||
`${NEW}FakeWiredService();`,
|
||||
`${NEW}WiredService(${NEW}PlainService());`,
|
||||
].join('\n'),
|
||||
};
|
||||
const fails = run('test-new-service-ratchet.mjs', serverRoot(files, 'test-new-service-baseline.json'));
|
||||
expect(fails.status).toBe(1);
|
||||
expect(fails.out).toContain('FAIL tests/unit/a.test.ts: 1 hand-built service(s) or repository(ies)');
|
||||
|
||||
const held = { counts: { 'tests/unit/a.test.ts': 1 } };
|
||||
expect(run('test-new-service-ratchet.mjs', serverRoot(files, 'test-new-service-baseline.json', held)).status).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-014: fails closed on a missing baseline', () => {
|
||||
const { status, out } = run('test-new-service-ratchet.mjs', serverRoot({ 'tests/a.test.ts': '' }, 'x.json', null));
|
||||
expect(status).toBe(1);
|
||||
expect(out).toContain('test-new-service-baseline.json cannot be read');
|
||||
});
|
||||
|
||||
it('TEST-RATCHET-015: the committed baseline holds this repository', () => {
|
||||
const { status, out } = run('test-new-service-ratchet.mjs', null);
|
||||
expect(out).toContain('held at their baseline');
|
||||
expect(status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the repository root the scripts default to', () => {
|
||||
it('TEST-RATCHET-020: is this server workspace', () => {
|
||||
expect(readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).toContain('"lint:test-mocks"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user