release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import type { CliFinding } from '../doctor.mts';
import {
@@ -490,3 +492,44 @@ describe('gatherFindings — album checks (#629)', () => {
}
});
});
/**
* `npm run doctor` runs the settings-values check the panel runs (QA A-14).
* Under Vitest an extensionless import resolves fine, so the second test
* loads the script in a real Node process: that is the runtime which refuses
* one, and the reason the CLI could not run this check before.
*/
describe('gatherFindings — setting values (QA A-14)', () => {
it('warns about values the resolvers would clamp or replace', async () => {
const dir = tmpdir();
try {
fs.writeFileSync(
path.join(dir, 'settings.yaml'),
'theme:\n accent: rot\ncontact:\n retentionDays: 400\n',
);
const findings = await gatherFindings(dir, { INSTALL_CONTENT_DIR: dir });
const finding = findings.find((f) => f.id === 'settings-values');
expect(finding?.level).toBe('warn');
expect(finding?.detail).toContain('accent colour, message retention');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('loads under Node type stripping, without a bundler resolving paths', () => {
const script = path.resolve(__dirname, '../doctor.mts');
const result = spawnSync(
process.execPath,
[
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--input-type=module',
'-e',
`const m = await import(${JSON.stringify(pathToFileURL(script).href)});` +
'process.stdout.write(typeof m.gatherFindings);',
],
{ encoding: 'utf8' },
);
expect(result.stderr).toBe('');
expect(result.stdout).toBe('function');
});
});

View File

@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
/**
* `npm run doctor` inside the shipped image runs the TypeScript sources with
* Node's own type stripping (#521). That holds two constraints no other test
* sees, because Vitest and the Next build resolve imports for themselves:
*
* - every runtime relative import needs its file extension, or Node refuses
* to load it (ERR_MODULE_NOT_FOUND);
* - every file the script reaches has to be COPY'd into the runner stage of
* the Dockerfile — the standalone bundle carries none of them, so a new
* import in the doctor's graph is a module-not-found in production.
*
* Both are checked here by walking the import graph from the entry point, so
* a module that joins the graph later (a theme.ts that starts importing a
* neighbour, say) fails this test until the Dockerfile follows.
*/
const ROOT = path.resolve(__dirname, '../..');
const ENTRY = 'scripts/doctor.mjs';
const EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
/** Block comments and whole-line `//` comments — prose mentioning `from './x'`
* must not count as an import. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
/** Runtime import specifiers of one module. `import type` / `export type`
* are erased by type stripping and skipped; an `import { type X }` is not —
* Node still loads that module, so it counts. */
function runtimeImports(source: string): string[] {
const code = stripComments(source);
const specs: string[] = [];
const staticRe = /(?:^|[\n;])\s*(import|export)\s+(type\s+)?[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
for (const m of code.matchAll(staticRe)) {
if (!m[2]) specs.push(m[3]);
}
for (const m of code.matchAll(/(?:^|[\n;])\s*import\s*['"]([^'"]+)['"]/g)) specs.push(m[1]);
for (const m of code.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(m[1]);
return specs;
}
interface Closure {
files: string[];
packages: string[];
problems: string[];
}
function walkClosure(entry: string): Closure {
const files = new Set<string>();
const packages = new Set<string>();
const problems: string[] = [];
const queue = [entry];
while (queue.length) {
const rel = queue.shift() as string;
if (files.has(rel)) continue;
files.add(rel);
const source = fs.readFileSync(path.join(ROOT, rel), 'utf8');
for (const spec of runtimeImports(source)) {
if (spec.startsWith('node:')) continue;
if (!spec.startsWith('.')) {
packages.add(
spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0],
);
continue;
}
let target = path.posix.join(path.posix.dirname(rel), spec);
if (!EXTENSIONS.includes(path.posix.extname(target))) {
problems.push(`${rel}: '${spec}' has no file extension`);
// Keep walking so one report lists every gap.
target = `${target}.ts`;
}
if (!fs.existsSync(path.join(ROOT, target))) {
problems.push(`${rel}: '${spec}' resolves to ${target}, which does not exist`);
continue;
}
queue.push(target);
}
}
return { files: [...files].sort(), packages: [...packages].sort(), problems };
}
interface CopyLine {
from: string | null;
sources: string[];
dest: string;
}
/** COPY instructions of the Dockerfile's `runner` stage, sources relative to
* /app and destinations relative to its WORKDIR (/app). */
function runnerCopies(): CopyLine[] {
const dockerfile = fs.readFileSync(path.join(ROOT, 'Dockerfile'), 'utf8').replace(/\\\n/g, ' ');
const stages = dockerfile.split(/^FROM\s+/m);
const runner = stages.find((stage) => /^\S+\s+AS\s+runner\b/i.test(stage));
if (!runner) throw new Error('Dockerfile has no runner stage');
const strip = (p: string) =>
p
.replace(/^\/app\//, '')
.replace(/^\.\//, '')
.replace(/\/$/, '');
return runner
.split('\n')
.filter((line) => /^COPY\s/i.test(line.trim()))
.map((line) => {
const tokens = line.trim().split(/\s+/).slice(1);
const from = tokens.find((t) => t.startsWith('--from='))?.slice('--from='.length) ?? null;
const paths = tokens.filter((t) => !t.startsWith('--'));
return { from, sources: paths.slice(0, -1).map(strip), dest: strip(paths[paths.length - 1]) };
});
}
/** Whether `file` lands at the same relative path in the image, either as
* itself or inside a copied directory. */
function isCopied(file: string, copies: CopyLine[], stage: string): boolean {
return copies.some(
(copy) =>
copy.from === stage &&
copy.sources.some(
(src) =>
(src === file && copy.dest === file) || (file.startsWith(`${src}/`) && copy.dest === src),
),
);
}
describe('the doctor in the runtime image', () => {
const closure = walkClosure(ENTRY);
it('reaches the modules it is known to need', () => {
// A guard on the walker itself: if this shrinks, the parser broke rather
// than the graph.
expect(closure.files).toEqual(
expect.arrayContaining([
'scripts/doctor.mjs',
'scripts/doctor.mts',
'lib/admin/doctor.ts',
'lib/config/settingValues.ts',
]),
);
expect(closure.packages).toContain('js-yaml');
});
it('writes every runtime relative import with its file extension', () => {
expect(closure.problems).toEqual([]);
});
it('copies every module of the graph into the runner stage', () => {
const copies = runnerCopies();
const missing = closure.files.filter((file) => !isCopied(file, copies, 'builder'));
expect(missing).toEqual([]);
});
it('copies every package the graph imports into the runner stage', () => {
const copies = runnerCopies();
const missing = closure.packages.filter(
(pkg) => !isCopied(`node_modules/${pkg}/package.json`, copies, 'deps'),
);
expect(missing).toEqual([]);
});
});
describe('runtimeImports', () => {
it('skips type-only imports and keeps inline type specifiers', () => {
const source = [
"import type { A } from './a';",
"export type { B } from './b';",
"import { type C, d } from './c.ts';",
"import { type E } from './e.ts';",
"export { f } from './f.ts';",
"import './g.ts';",
"const h = await import('js-yaml');",
"// import { x } from './commented';",
"/* import { y } from './block'; */",
].join('\n');
expect(runtimeImports(source)).toEqual(['./c.ts', './e.ts', './f.ts', './g.ts', 'js-yaml']);
});
it('reads an import spread over several lines', () => {
expect(runtimeImports("import {\n a,\n type B,\n} from '../x.ts';")).toEqual(['../x.ts']);
});
});