ci(test): type-check Jest spec programs and gate it in CI (#1705)

* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 20:54:27 +02:00
1 parent 69056487bc
commit 650da4a1d3
158 files changed
+1517 -632

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
/**
* Type-checks every Jest spec program in the workspace.
*
* ts-jest runs with `isolatedModules`, so it transpiles each file on its own
* and never reports type errors: a spec that no longer matches the code it
* exercises still passes as long as it runs. This gate runs `tsc --noEmit`
* over every `tsconfig.spec.json` (the same configs Jest hands to ts-jest)
* with a small concurrency pool and fails when any program reports an error.
*
* Usage: node tools/typecheck/spec-typecheck.mjs [--concurrency=N] [filter...]
* A positional filter keeps only configs whose path contains it. Run
* `pnpm run typecheck:spec` from the workspace root.
*/
import { spawn } from 'node:child_process';
import { readdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const SPEC_TSCONFIG = 'tsconfig.spec.json';
const SEARCH_ROOTS = ['apps', 'libs', 'tools'];
const SKIPPED_DIRECTORIES = new Set([
'node_modules',
'dist',
'coverage',
'tmp',
'.nx',
'.angular',
]);
const TSC_ERROR = /(?:^|\s)error TS\d+:/;
/**
* Finds every `tsconfig.spec.json` under the search roots, sorted so the
* output and the pool order stay stable between runs.
*/
export function discoverSpecTsconfigs(workspaceRoot, roots = SEARCH_ROOTS) {
const found = [];
function walk(directory) {
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) {
walk(path.join(directory, entry.name));
}
} else if (entry.isFile() && entry.name === SPEC_TSCONFIG) {
found.push(
path
.relative(
workspaceRoot,
path.join(directory, entry.name)
)
.split(path.sep)
.join('/')
);
}
}
}
for (const root of roots) {
walk(path.join(workspaceRoot, root));
}
return found.sort();
}
/** Keeps the configs whose path contains at least one of the filters. */
export function filterConfigs(configs, filters) {
if (filters.length === 0) return configs;
return configs.filter((config) =>
filters.some((filter) => config.includes(filter))
);
}
/**
* Splits `tsc --pretty false` output into error lines and the rest. Continuation
* lines (indented detail of a multi-line diagnostic) stay with the errors so
* the printed block is complete, but only lines carrying a TS code count.
*/
export function parseTscOutput(output) {
const lines = output.split(/\r?\n/);
const errors = [];
let errorCount = 0;
let inError = false;
for (const line of lines) {
if (TSC_ERROR.test(line)) {
errorCount += 1;
inError = true;
errors.push(line);
} else if (inError && /^\s+\S/.test(line)) {
errors.push(line);
} else {
inError = false;
}
}
return { errorCount, errors };
}
/**
* Concurrency defaults to the core count minus one, capped at four: each tsc
* process is single-threaded but allocates hundreds of megabytes for the
* Angular type graph, so more slots mostly trade memory for no wall-clock gain.
*/
export function resolveConcurrency({ requested, cpuCount }) {
if (Number.isInteger(requested) && requested > 0) return requested;
return Math.max(1, Math.min(4, cpuCount - 1));
}
/**
* Runs every task with at most `concurrency` in flight and resolves with all
* results in task order. Unlike the coverage runner this does not fail fast:
* a type-check gate is only useful when it reports every failing program.
*/
export async function runAll(tasks, { concurrency, onSettled }) {
const results = new Array(tasks.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < tasks.length) {
const index = nextIndex++;
const task = tasks[index];
const startedAt = Date.now();
let outcome;
try {
outcome = await task.run();
} catch (error) {
outcome = { errorCount: 1, errors: [String(error)], status: 1 };
}
const settled = {
name: task.name,
...outcome,
durationMs: Date.now() - startedAt,
};
results[index] = settled;
onSettled?.(settled);
}
}
const workers = [];
for (let slot = 0; slot < Math.max(1, concurrency); slot += 1) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
export function formatDuration(ms) {
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
return minutes > 0
? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
: `${seconds}s`;
}
/** Summary table: failing programs first, then the slowest ones. */
export function formatSummary(results, totalMs) {
const failed = results.filter((result) => result.errorCount > 0);
const lines = [];
const sorted = [...results].sort(
(a, b) =>
b.errorCount - a.errorCount ||
b.durationMs - a.durationMs ||
a.name.localeCompare(b.name)
);
const width = Math.max(...results.map((result) => result.name.length), 8);
lines.push(`${'Program'.padEnd(width)} Errors Duration`);
for (const result of sorted) {
lines.push(
`${result.name.padEnd(width)} ${String(result.errorCount).padStart(6)} ${formatDuration(result.durationMs)}`
);
}
const totalErrors = failed.reduce(
(sum, result) => sum + result.errorCount,
0
);
lines.push('');
lines.push(
failed.length === 0
? `All ${results.length} spec programs type-check (${formatDuration(totalMs)}).`
: `${failed.length} of ${results.length} spec programs failed with ${totalErrors} error(s) (${formatDuration(totalMs)}).`
);
return lines.join('\n');
}
function parsePositiveInteger(raw, what) {
if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) {
throw new Error(
`${what} expects a positive integer, received "${raw}".`
);
}
return Number.parseInt(raw, 10);
}
export function parseArgs(argv, env) {
let concurrency;
const filters = [];
for (const argument of argv) {
if (argument.startsWith('--concurrency=')) {
concurrency = parsePositiveInteger(
argument.slice('--concurrency='.length),
'--concurrency='
);
} else if (argument.startsWith('--')) {
throw new Error(`Unknown option "${argument}".`);
} else {
filters.push(argument);
}
}
const fromEnv = env.SPEC_TYPECHECK_CONCURRENCY;
if (concurrency === undefined && fromEnv !== undefined && fromEnv !== '') {
concurrency = parsePositiveInteger(
fromEnv,
'SPEC_TYPECHECK_CONCURRENCY'
);
}
return { concurrency, filters };
}
function runTsc(workspaceRoot, config) {
return new Promise((resolve, reject) => {
const tscBin = path.join(
workspaceRoot,
'node_modules',
'typescript',
'bin',
'tsc'
);
const child = spawn(
process.execPath,
[tscBin, '-p', config, '--noEmit', '--pretty', 'false'],
{ cwd: workspaceRoot, stdio: ['ignore', 'pipe', 'pipe'] }
);
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
child.once('error', reject);
child.once('close', (status, signal) => {
const parsed = parseTscOutput(output);
if (parsed.errorCount === 0 && (status !== 0 || signal)) {
parsed.errorCount = 1;
parsed.errors = [
`tsc exited with ${signal ?? `status ${status}`} without reporting a diagnostic:`,
output.trim(),
];
}
resolve({ ...parsed, status: status ?? 1 });
});
});
}
export async function main(argv = process.argv.slice(2), env = process.env) {
const workspaceRoot = process.cwd();
const { concurrency: requested, filters } = parseArgs(argv, env);
const configs = filterConfigs(
discoverSpecTsconfigs(workspaceRoot),
filters
);
if (configs.length === 0) {
console.error('No tsconfig.spec.json matched.');
return 1;
}
const concurrency = resolveConcurrency({
requested,
cpuCount: os.availableParallelism?.() ?? os.cpus().length,
});
console.log(
`Type-checking ${configs.length} spec program(s), ${concurrency} at a time.`
);
const startedAt = Date.now();
const results = await runAll(
configs.map((config) => ({
name: config,
run: () => runTsc(workspaceRoot, config),
})),
{
concurrency,
onSettled(result) {
if (result.errorCount === 0) {
console.log(
`ok ${result.name} (${formatDuration(result.durationMs)})`
);
return;
}
console.log(
`FAIL ${result.name}: ${result.errorCount} error(s) (${formatDuration(result.durationMs)})`
);
for (const line of result.errors) {
console.log(` ${line}`);
}
},
}
);
console.log('');
console.log(formatSummary(results, Date.now() - startedAt));
return results.some((result) => result.errorCount > 0) ? 1 : 0;
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main()
.then((status) => {
process.exitCode = status;
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { after, before, test } from 'node:test';
import {
discoverSpecTsconfigs,
filterConfigs,
formatSummary,
parseArgs,
parseTscOutput,
resolveConcurrency,
runAll,
} from './spec-typecheck.mjs';
let workDir;
before(async () => {
workDir = await mkdtemp(path.join(os.tmpdir(), 'spec-typecheck-'));
});
after(async () => {
await rm(workDir, { recursive: true, force: true });
});
test('discovers spec tsconfigs under the search roots and skips build output', async () => {
const dirs = [
'apps/web',
'libs/a/b',
'libs/a/node_modules/dep',
'libs/dist/thing',
'tools/x',
'other/y',
];
for (const dir of dirs) {
await mkdir(path.join(workDir, dir), { recursive: true });
await writeFile(path.join(workDir, dir, 'tsconfig.spec.json'), '{}');
}
await writeFile(path.join(workDir, 'libs/a/b/tsconfig.json'), '{}');
assert.deepEqual(discoverSpecTsconfigs(workDir), [
'apps/web/tsconfig.spec.json',
'libs/a/b/tsconfig.spec.json',
'tools/x/tsconfig.spec.json',
]);
assert.deepEqual(discoverSpecTsconfigs(workDir, ['missing']), []);
});
test('filters configs by path substring', () => {
const configs = [
'apps/web/tsconfig.spec.json',
'libs/epg/tsconfig.spec.json',
];
assert.deepEqual(filterConfigs(configs, []), configs);
assert.deepEqual(filterConfigs(configs, ['epg']), [
'libs/epg/tsconfig.spec.json',
]);
assert.deepEqual(filterConfigs(configs, ['nope']), []);
});
test('counts tsc diagnostics and keeps their continuation lines', () => {
const output = [
'libs/x/a.spec.ts(3,5): error TS2322: Type A is not assignable to type B.',
" Property 'c' is missing in type 'A'.",
'libs/x/b.spec.ts(9,1): error TS2304: Cannot find name Foo.',
'error TS6059: File is not under rootDir.',
'',
].join('\n');
const parsed = parseTscOutput(output);
assert.equal(parsed.errorCount, 3);
assert.equal(parsed.errors.length, 4);
assert.deepEqual(parseTscOutput(''), { errorCount: 0, errors: [] });
});
test('caps the default concurrency and honours an explicit request', () => {
assert.equal(resolveConcurrency({ cpuCount: 2 }), 1);
assert.equal(resolveConcurrency({ cpuCount: 12 }), 4);
assert.equal(resolveConcurrency({ requested: 7, cpuCount: 2 }), 7);
});
test('parses concurrency from flags or the environment and rejects junk', () => {
assert.deepEqual(parseArgs(['--concurrency=2', 'epg'], {}), {
concurrency: 2,
filters: ['epg'],
});
assert.deepEqual(parseArgs([], { SPEC_TYPECHECK_CONCURRENCY: '3' }), {
concurrency: 3,
filters: [],
});
assert.throws(
() => parseArgs(['--concurrency=zero'], {}),
/positive integer/
);
assert.throws(() => parseArgs(['--verbose'], {}), /Unknown option/);
});
test('runs every task even after a failure and keeps task order', async () => {
const order = [];
const tasks = ['a', 'b', 'c', 'd'].map((name, index) => ({
name,
async run() {
order.push(name);
await new Promise((resolve) =>
setTimeout(resolve, 5 * (4 - index))
);
return { errorCount: name === 'a' ? 2 : 0, errors: [], status: 0 };
},
}));
const settled = [];
const results = await runAll(tasks, {
concurrency: 2,
onSettled: (result) => settled.push(result.name),
});
assert.deepEqual(order, ['a', 'b', 'c', 'd']);
assert.deepEqual(
results.map((result) => [result.name, result.errorCount]),
[
['a', 2],
['b', 0],
['c', 0],
['d', 0],
]
);
assert.equal(settled.length, 4);
assert.ok(results.every((result) => result.durationMs >= 0));
});
test('turns a thrown task into a failing result', async () => {
const results = await runAll(
[
{
name: 'boom',
run: () => Promise.reject(new Error('spawn failed')),
},
],
{ concurrency: 1 }
);
assert.equal(results[0].errorCount, 1);
assert.match(results[0].errors[0], /spawn failed/);
});
test('summarises failures first and reports the totals', () => {
const summary = formatSummary(
[
{
name: 'apps/web/tsconfig.spec.json',
errorCount: 0,
durationMs: 12_000,
},
{
name: 'libs/epg/tsconfig.spec.json',
errorCount: 3,
durationMs: 4_000,
},
],
70_000
);
const lines = summary.split('\n');
assert.match(lines[1], /^libs\/epg\/tsconfig\.spec\.json\s+3\s+4s$/);
assert.match(lines[2], /^apps\/web\/tsconfig\.spec\.json\s+0\s+12s$/);
assert.match(
summary,
/1 of 2 spec programs failed with 3 error\(s\) \(1m 10s\)/
);
const clean = formatSummary(
[{ name: 'a', errorCount: 0, durationMs: 100 }],
100
);
assert.match(clean, /All 1 spec programs type-check \(0s\)/);
});