release: v0.20.0
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
186
scripts/__tests__/doctor-image.test.ts
Normal file
186
scripts/__tests__/doctor-image.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user