Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9

# Conflicts:
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts
This commit is contained in:
4gray committed 2026-09-27 20:55:48 +02:00
commit 02ed4e589c
378 files changed
+16812 -1098

No files matched your search

+2 -2
View File
@@ -3,10 +3,10 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 1626019,
"value": 1598232,
"unit": "bytes",
"updatedAt": "2026-09-27",
"evidencePr": 1712,
"evidencePr": 1734,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
+4
View File
@@ -15,6 +15,10 @@ const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const SITE = 'https://4gray.github.io/iptvnator';
const GUIDES = [
{
slug: 'm3u-programme-guide',
screenshots: [],
},
{
slug: 'xtream-codes-setup-guide',
screenshots: [
+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\)/);
});