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']);
});
});

View File

@@ -26,8 +26,22 @@ import { chromium } from '@playwright/test';
const require = createRequire(import.meta.url);
const axeSource = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
// Keep in sync with the presets in lib/config/theme.ts.
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
// Keep in sync with PRESET_REGISTRY in lib/config/presets.ts
// (lib/__tests__/preset-registry.test.ts fails when they drift).
const PRESETS = [
'studio-modern',
'studio',
'minimal',
'editorial',
'classic',
'noir',
'monograph',
'kunsthalle',
'ma',
'cyanotype',
'salon',
'birch',
];
const FIXED_PAGES = ['/about', '/map', '/impressum'];
const print = (line = '') => process.stdout.write(line + '\n');

View File

@@ -21,11 +21,16 @@
* Node 22.18 unflagged native type stripping, so `node scripts/doctor.mts`
* runs the TypeScript directly with no runner at all. It has one constraint:
* no path aliases, and every relative import needs its file extension. That
* rules out importing `lib/config` — but `lib/admin/doctor.ts` has no imports
* whatsoever (it is pure functions over gathered inputs, written that way in
* #491 precisely so a CLI could reuse it), so the entire TypeScript module
* graph of this script is two files. Nothing is duplicated that judges: the
* checks all come from `lib/admin/doctor.ts`.
* rules out importing `lib/config/index.ts` and most of its neighbours — but
* `lib/admin/doctor.ts` has no imports whatsoever (it is pure functions over
* gathered inputs, written that way in #491 precisely so a CLI could reuse
* it), and `lib/config/settingValues.ts` writes its few imports with the
* extension, onto modules that only `import type` further. So the TypeScript
* module graph of this script stays small, and nothing is duplicated that
* judges: the checks all come from `lib/admin/doctor.ts`, the setting values
* are judged by the same function the save route runs. A test in
* scripts/__tests__/doctor-cli.test.ts loads the graph in a real Node process,
* because Vitest resolves extensionless imports and would not notice.
*
* What *is* repeated here is the gathering — reading the filesystem and the
* environment instead of a request, exactly as the issue described. It is
@@ -58,6 +63,7 @@ import {
checkLegal,
checkPrivacy,
checkPasswords,
checkSettingValues,
worstLevel,
type AlbumRef,
type DoctorFinding,
@@ -66,6 +72,7 @@ import {
type LegalRef,
type PasswordRef,
} from '../lib/admin/doctor.ts';
import { validateSettingValues } from '../lib/config/settingValues.ts';
/**
* `process.env`, minus the `NODE_ENV`-is-required augmentation Next adds to
@@ -707,7 +714,7 @@ export async function gatherFindings(cwd: string, env: EnvLike): Promise<CliFind
findings.push(checkPasswords(passwords));
// ── Impressum ───────────────────────────────────────────────────────
// ── Impressum, privacy and setting values ───────────────────────────
const settingsNode = settings.node;
if (settingsNode && typeof settingsNode === 'object' && !Array.isArray(settingsNode)) {
const node = settingsNode as Record<string, unknown>;
@@ -730,6 +737,9 @@ export async function gatherFindings(cwd: string, env: EnvLike): Promise<CliFind
hasText: privacyText !== '',
});
if (privacy) findings.push(privacy);
// Values the resolvers would clamp or replace — the same check the panel's
// Diagnostics runs over the raw file.
findings.push(checkSettingValues(validateSettingValues(node)));
}
// ── Writability of the content volume ───────────────────────────────

View File

@@ -56,13 +56,14 @@ import { spawn, type ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { PRESET_IDS } from '../lib/config/presets';
import type { Browser, Page } from '@playwright/test';
// Next reads .env.local itself; this script runs outside Next, so ADMIN_PASSWORD
// and friends have to be pulled in explicitly. Must run before the constants below.
loadEnvLocal();
const THEMES = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const THEMES = [...PRESET_IDS];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
/** Preset used for everything that is not itself a theme comparison. */