This commit is contained in:
+41
-45
@@ -5,7 +5,7 @@
|
||||
* npm run bench # all tasks
|
||||
* npm run bench -- --task=composite-mini-report
|
||||
* npm run bench -- --server=http://127.0.0.1:9876
|
||||
* BENCH_JUDGE=off npm run bench # skip LLM judge (axis D fixed at 1.0)
|
||||
* BENCH_JUDGE=off npm run bench # skip LLM judge (axis D not graded, excluded from the total)
|
||||
*
|
||||
* Prereq: scripts/server.sh start (or `npm start`) running on the configured port.
|
||||
*/
|
||||
@@ -14,15 +14,10 @@ import * as path from 'path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { startFixtureServer } from '../src/bench/fixture-server.js';
|
||||
import { runOneTask } from '../src/bench/runner.js';
|
||||
import {
|
||||
computeTotal,
|
||||
gradeChecklist,
|
||||
gradeInstructionsProgrammatic,
|
||||
gradeTools,
|
||||
} from '../src/bench/grader.js';
|
||||
import { gradeReasoning, loadJudgeConfigFromEnv } from '../src/bench/judge.js';
|
||||
import { writeRunSummary } from '../src/bench/summary.js';
|
||||
import type { BenchResult, BenchTask, RawJobResult } from '../src/bench/types.js';
|
||||
import { exitCodeForResults, runAndGradeTask } from '../src/bench/grade-run.js';
|
||||
import { loadJudgeConfigFromProvider } from '../src/bench/judge.js';
|
||||
import { formatAxisCell, formatTotalCell, writeRunSummary } from '../src/bench/summary.js';
|
||||
import type { BenchResult, BenchTask } from '../src/bench/types.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
|
||||
@@ -87,12 +82,7 @@ function copyWorkspaceArtifacts(rawWorkspace: string, dest: string): void {
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
const config = loadConfig();
|
||||
const fallbackWorker = config.provider.workers?.[0];
|
||||
const judgeConfig = loadJudgeConfigFromEnv({
|
||||
endpoint: fallbackWorker?.endpoint ?? config.provider.baseUrl ?? 'http://127.0.0.1:11434/v1',
|
||||
model: fallbackWorker?.model ?? config.provider.model ?? '',
|
||||
apiKey: config.provider.apiKey ?? undefined,
|
||||
});
|
||||
const judgeConfig = loadJudgeConfigFromProvider(config.provider);
|
||||
|
||||
const tasks = loadTasks(args.benchRoot, args.taskFilter);
|
||||
if (tasks.length === 0) {
|
||||
@@ -108,37 +98,25 @@ async function main(): Promise<void> {
|
||||
const results: BenchResult[] = [];
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const startedAt = new Date().toISOString();
|
||||
logger.info(`[bench] === task: ${task.id} ===`);
|
||||
let raw: RawJobResult;
|
||||
try {
|
||||
raw = await runOneTask(task, {
|
||||
serverUrl: args.serverUrl,
|
||||
benchRoot: args.benchRoot,
|
||||
webPort: fixtureServer.port,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[bench] runner failed for ${task.id}: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
const tools = gradeTools(task, raw);
|
||||
const checklist = gradeChecklist(task, raw);
|
||||
const instructions = gradeInstructionsProgrammatic(task, raw);
|
||||
const reasoning = await gradeReasoning(task, raw, judgeConfig);
|
||||
const finishedAt = new Date().toISOString();
|
||||
const result: BenchResult = {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
raw,
|
||||
axes: { tools, checklist, instructions, reasoning },
|
||||
total: 0,
|
||||
};
|
||||
result.total = computeTotal(result.axes);
|
||||
const taskResultDir = path.join(args.resultDir, task.id);
|
||||
fs.mkdirSync(taskResultDir, { recursive: true });
|
||||
copyWorkspaceArtifacts(raw.workspacePath, path.join(taskResultDir, 'workspace'));
|
||||
|
||||
const result = await runAndGradeTask(task, {
|
||||
runTask: (t) =>
|
||||
runOneTask(t, {
|
||||
serverUrl: args.serverUrl,
|
||||
benchRoot: args.benchRoot,
|
||||
webPort: fixtureServer.port,
|
||||
}),
|
||||
judgeConfig,
|
||||
});
|
||||
|
||||
if (result.outcome === 'failed') {
|
||||
logger.error(`[bench] runner failed for ${task.id}: ${result.error}`);
|
||||
} else if (result.raw) {
|
||||
copyWorkspaceArtifacts(result.raw.workspacePath, path.join(taskResultDir, 'workspace'));
|
||||
}
|
||||
fs.writeFileSync(path.join(taskResultDir, 'result.json'), JSON.stringify(result, null, 2), 'utf-8');
|
||||
results.push(result);
|
||||
}
|
||||
@@ -149,10 +127,28 @@ async function main(): Promise<void> {
|
||||
const summaryPath = writeRunSummary(args.resultDir, results);
|
||||
logger.info(`[bench] wrote ${summaryPath}`);
|
||||
// Concise stdout summary so CI / humans see totals without opening the file.
|
||||
// Axes that were not graded print their status, never a percentage, and the
|
||||
// scored weight travels with the total so two runs are comparable on purpose.
|
||||
for (const r of results) {
|
||||
if (r.outcome === 'failed') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${r.taskId}: FAILED (not scored) error=${r.error ?? 'unknown'}`);
|
||||
continue;
|
||||
}
|
||||
const weight = r.scoring ? `${r.scoring.scoredWeight}/${r.scoring.maxWeight}` : '?';
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${r.taskId}: total=${r.total}/100 status=${r.raw.status} tools=${(r.axes.tools.score * 100).toFixed(0)}% checklist=${(r.axes.checklist.score * 100).toFixed(0)}% instr=${(r.axes.instructions.score * 100).toFixed(0)}% reasoning=${(r.axes.reasoning.score * 100).toFixed(0)}%`);
|
||||
console.log(
|
||||
`${r.taskId}: total=${formatTotalCell(r)}/100 (scored weight ${weight}) status=${r.raw?.status ?? '-'} ` +
|
||||
`tools=${formatAxisCell(r.axes?.tools)} checklist=${formatAxisCell(r.axes?.checklist)} ` +
|
||||
`instr=${formatAxisCell(r.axes?.instructions)} reasoning=${formatAxisCell(r.axes?.reasoning)}`,
|
||||
);
|
||||
}
|
||||
const failedCount = results.filter((r) => r.outcome === 'failed').length;
|
||||
if (failedCount > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${failedCount} of ${results.length} task(s) failed to run and were not scored.`);
|
||||
}
|
||||
process.exitCode = exitCodeForResults(results);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -39,6 +39,9 @@ npm run build:server
|
||||
|
||||
echo "[4/5] Building UI"
|
||||
npm --prefix ui run build
|
||||
# Mermaid bundle for the server-side renderer (#1025). Lives here as well as in
|
||||
# the npm build:ui script because this path calls the UI build directly.
|
||||
node scripts/copy-mermaid-vendor.mjs
|
||||
|
||||
if [ "$SKIP_PYTHON" -eq 0 ]; then
|
||||
echo "[5/5] Pre-baking Python packages for the Bash sandbox"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// scripts/check-dark-text.mjs
|
||||
//
|
||||
// Counts `dark:` + `text-slate-<n>` class names and fails when there are more
|
||||
// than the baseline records.
|
||||
//
|
||||
// WHY
|
||||
// ---
|
||||
// ui/src/index.css INVERTS the slate ramp for the dark theme: `--slate-200` is
|
||||
// a pale grey in light and a dark near-ground tone (#2f2f37) in dark, `--slate-700` is
|
||||
// #334155 in light and a pale grey in dark. So a bare `text-slate-700` is
|
||||
// already the right ink in BOTH themes. Adding `dark:text-slate-200` on top
|
||||
// flips it a second time and the text lands on the dark theme's background
|
||||
// colour — measured at 1.17:1 to 1.97:1 on the five spots fixed in #1388.
|
||||
//
|
||||
// WHY "DON'T ADD MORE" AND NOT "NEVER"
|
||||
// ------------------------------------
|
||||
// The pattern is not always wrong. Where the surface stays light in the dark
|
||||
// theme -- a literal `bg-white` card, for instance -- dark ink IS the correct
|
||||
// choice, and PromptCoachPanel.tsx has exactly such a case. So the existing
|
||||
// uses stay in the baseline and only new ones fail.
|
||||
//
|
||||
// WHAT THIS CHECK DOES NOT SEE
|
||||
// ----------------------------
|
||||
// - It does not measure contrast. An element's real background comes from
|
||||
// its ancestors, and that cannot be decided from static text. A check that
|
||||
// pretended to measure it would be worse than no check at all.
|
||||
// - It therefore also stops correct code: on a surface that stays light in
|
||||
// the dark theme (a literal `bg-white`), dark ink is right and this check
|
||||
// still counts it. Baseline it deliberately, with a comment saying why.
|
||||
// - The scan is plain text, like Tailwind's own class extractor, so a class
|
||||
// name written inside a comment or a test fixture counts too. One such
|
||||
// mention is in the baseline on purpose (CacheWorkerCards.tsx documents
|
||||
// the trap). To name the pattern in prose without being counted, write
|
||||
// `dark:text-slate-*` with a star instead of a number.
|
||||
// - It only looks at `slate`. `dark:text-gray-*`, `dark:text-zinc-*` and
|
||||
// every other ramp are invisible to it -- `gray` is inverted in
|
||||
// ui/src/index.css too, so the same trap exists there and is not guarded.
|
||||
// - Variant chains around `dark:` are counted (`dark:hover:text-slate-300`,
|
||||
// `group-hover:dark:text-slate-300`), but a colour set from JS (a ternary
|
||||
// building the class name from pieces) is not.
|
||||
// - It counts occurrences per file, not per element. Moving a use from one
|
||||
// file to another reads as one addition plus one removal, and the addition
|
||||
// fails until the baseline is refreshed.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-dark-text.mjs # verify against baseline
|
||||
// node scripts/check-dark-text.mjs --update # rewrite the baseline
|
||||
// node scripts/check-dark-text.mjs --ui <dir> --baseline <file>
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, relative, join, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
|
||||
/** `--flag value` / `--flag=value`; returns undefined when absent. */
|
||||
function argValue(flag) {
|
||||
const argv = process.argv.slice(2);
|
||||
const i = argv.indexOf(`--${flag}`);
|
||||
if (i !== -1 && argv[i + 1]) return argv[i + 1];
|
||||
const inline = argv.find((a) => a.startsWith(`--${flag}=`));
|
||||
return inline ? inline.slice(flag.length + 3) : undefined;
|
||||
}
|
||||
|
||||
// `--ui` / `--baseline` exist so the check can be exercised against a fixture
|
||||
// tree (see scripts/check-dark-text.test.ts). Normal runs take neither.
|
||||
const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui');
|
||||
const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js');
|
||||
const BASELINE_PATH = argValue('baseline')
|
||||
? resolve(ROOT, argValue('baseline'))
|
||||
: resolve(__dirname, 'dark-text-baseline.json');
|
||||
|
||||
const UPDATE = process.argv.includes('--update');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. What to look for
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A variant chain (`dark:`, `dark:hover:`, `md:dark:`, `group-hover:dark:`)
|
||||
// with `dark` somewhere in it, ending in `text-slate-<number>`.
|
||||
const VARIANT = String.raw`(?:[a-z][\w-]*:)`;
|
||||
const CLASS_RE = new RegExp(
|
||||
String.raw`(?<![\w./:-])(${VARIANT}*dark:${VARIANT}*text-slate-\d+)(?![\w./-])`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Which files does Tailwind scan?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = (await import(pathToFileURL(CONFIG_PATH).href)).default;
|
||||
} catch (err) {
|
||||
console.error(`dark-text: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``);
|
||||
console.error(String(err?.message ?? err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Turn a Tailwind content glob into { dir, test } we can walk. */
|
||||
function compileGlob(pattern) {
|
||||
const clean = pattern.replace(/^\.\//, '');
|
||||
const parts = clean.split('/');
|
||||
const firstMagic = parts.findIndex((p) => /[*?{[]/.test(p));
|
||||
const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/');
|
||||
const rx = globToRegExp(clean);
|
||||
return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) };
|
||||
}
|
||||
|
||||
/** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */
|
||||
function globToRegExp(glob) {
|
||||
let out = '';
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
i += 1;
|
||||
if (glob[i + 1] === '/') {
|
||||
i += 1;
|
||||
out += '(?:[^/]+/)*';
|
||||
} else {
|
||||
out += '.*';
|
||||
}
|
||||
} else {
|
||||
out += '[^/]*';
|
||||
}
|
||||
} else if (c === '?') {
|
||||
out += '[^/]';
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) {
|
||||
out += escapeRe(c);
|
||||
} else {
|
||||
out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`;
|
||||
i = end;
|
||||
}
|
||||
} else {
|
||||
out += escapeRe(c);
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${out}$`);
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!existsSync(dir)) return out;
|
||||
if (!statSync(dir).isDirectory()) {
|
||||
out.push(dir);
|
||||
return out;
|
||||
}
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.isFile()) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter(
|
||||
(p) => typeof p === 'string',
|
||||
);
|
||||
if (contentPatterns.length === 0) {
|
||||
console.error('dark-text: tailwind.config.js has no string `content` globs to scan.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = new Set();
|
||||
for (const pattern of contentPatterns) {
|
||||
const { dir, test } = compileGlob(pattern);
|
||||
for (const file of walk(dir)) {
|
||||
const relToUi = relative(UI_DIR, file).split(sep).join('/');
|
||||
if (test(relToUi)) files.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** key -> { file, class, count, lines[] } */
|
||||
const found = new Map();
|
||||
for (const file of [...files].sort()) {
|
||||
const relPath = relative(ROOT, file).split(sep).join('/');
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
for (const match of line.matchAll(CLASS_RE)) {
|
||||
const cls = match[1];
|
||||
const key = `${relPath} ${cls}`;
|
||||
const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] };
|
||||
hit.count += 1;
|
||||
hit.lines.push(i + 1);
|
||||
found.set(key, hit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const entries = [...found.values()]
|
||||
.sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class))
|
||||
.map(({ file, class: cls, count }) => ({ file, class: cls, count }));
|
||||
const total = entries.reduce((n, e) => n + e.count, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Compare with the baseline (or write it)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (UPDATE) {
|
||||
const payload = {
|
||||
_comment: [
|
||||
'Known `dark:` + text-slate-<n> class names (see scripts/check-dark-text.mjs).',
|
||||
'ui/src/index.css inverts the slate ramp for the dark theme, so a bare',
|
||||
'text-slate-<n> is already right in both themes and the dark: variant flips it',
|
||||
'a second time onto the background. Some of these are still correct — on a',
|
||||
'surface that stays light in the dark theme, dark ink is what you want.',
|
||||
'This file exists so the check fails on NEW ones only. Do not add to it by',
|
||||
'hand: fix the class, then run `npm run check:dark-text -- --update`.',
|
||||
],
|
||||
total,
|
||||
files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size,
|
||||
classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size,
|
||||
entries,
|
||||
};
|
||||
writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
console.log(`dark-text: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(BASELINE_PATH)) {
|
||||
console.error(`dark-text: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:dark-text -- --update\`.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count]));
|
||||
|
||||
const added = [];
|
||||
for (const [key, hit] of found) {
|
||||
const allowed = baselineCounts.get(key) ?? 0;
|
||||
if (hit.count > allowed) added.push({ ...hit, allowed });
|
||||
}
|
||||
|
||||
const removed = [];
|
||||
for (const [key, count] of baselineCounts) {
|
||||
const current = found.get(key)?.count ?? 0;
|
||||
if (current < count) removed.push({ key, count, current });
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
console.error('dark-text: new `dark:` text-slate class — the dark theme inverts this ramp, so this flips the ink twice.\n');
|
||||
for (const hit of added) {
|
||||
const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', ');
|
||||
const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : '';
|
||||
console.error(` ${hit.class}${extra}\n ${where}`);
|
||||
}
|
||||
console.error(`\n${added.length} new use(s). ui/src/index.css maps slate onto CSS variables and INVERTS the ramp for`);
|
||||
console.error('the dark theme (--slate-200 is #e2e8f0 in light and #2f2f37 in dark). A bare text-slate-<n>');
|
||||
console.error('is therefore already the right ink in both themes; the dark: variant flips it a second time');
|
||||
console.error('and the text lands on the background. Drop the variant:');
|
||||
console.error(' text-slate-700 dark:text-slate-200 -> text-slate-700');
|
||||
console.error('\nIf the surface under this text stays LIGHT in the dark theme (a literal bg-white card),');
|
||||
console.error('the dark ink is correct — pin the colour so it cannot flip, e.g. text-[#1e293b], or');
|
||||
console.error('record it with `npm run check:dark-text -- --update` and say in a comment why it belongs.');
|
||||
console.error('\nIf the surface is a hard-coded dark colour in both themes (bg-[#0b1020]), pin the ink to a');
|
||||
console.error('literal light value instead of any slate class, e.g. text-[#f1f5f9].');
|
||||
console.error('\nThis check does not measure contrast — see the header of scripts/check-dark-text.mjs.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
console.log(`dark-text: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`);
|
||||
console.log(' npm run check:dark-text -- --update');
|
||||
for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`);
|
||||
}
|
||||
|
||||
console.log(`dark-text: OK — ${total} known use(s), no new ones.`);
|
||||
@@ -0,0 +1,141 @@
|
||||
// scripts/check-dark-text.mjs が本当に効くことを守るテスト (#1388)。
|
||||
//
|
||||
// 守る対象は「いま何件あるか」ではなく、**暗い外観で二度反転する書き方を新しく
|
||||
// 足したら赤くなる経路が残っていること**。控え(baseline)方式は放っておくと
|
||||
// 「全部控えに入れて常に緑」になりやすいので、増えたら落ちる・減っても落ちない、
|
||||
// の両方を固定する。
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const script = join(repoRoot, 'scripts', 'check-dark-text.mjs');
|
||||
|
||||
// 見張りは content の並びから走査対象を決めるので、fixture にも同じ形の設定を置く。
|
||||
const FIXTURE_CONFIG = `export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: { extend: { colors: { slate: { 200: 'var(--slate-200)' } } } },
|
||||
};
|
||||
`;
|
||||
|
||||
// テスト自身のソースに丸ごとの class 名を書くと、このリポジトリ本体の控えに
|
||||
// 数えられてしまう。組み立てて使う。
|
||||
const DARK = 'dark:';
|
||||
const T = (n: number, ...variants: string[]) => `${DARK}${variants.join('')}text-slate-${n}`;
|
||||
|
||||
let dir: string;
|
||||
const ui = () => join(dir, 'ui');
|
||||
const baseline = () => join(dir, 'baseline.json');
|
||||
|
||||
function write(rel: string, body: string) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, body);
|
||||
}
|
||||
|
||||
function run(...extra: string[]) {
|
||||
return spawnSync(process.execPath, [script, '--ui', ui(), '--baseline', baseline(), ...extra], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'dark-text-'));
|
||||
write('ui/package.json', '{ "type": "module" }\n');
|
||||
write('ui/tailwind.config.js', FIXTURE_CONFIG);
|
||||
// index.html も content に載っているので、こちらの 1 件も拾われる。
|
||||
write('ui/index.html', `<div class="text-slate-700 ${T(200)}"></div>\n`);
|
||||
write(
|
||||
'ui/src/Panel.tsx',
|
||||
[
|
||||
'export const Panel = () => (',
|
||||
` <div className="text-slate-700 ${T(300, 'hover:')} dark:bg-slate-800 dark:text-emerald-300">`,
|
||||
` <span className="text-slate-600 group-hover:${T(400)} text-slate-900" />`,
|
||||
' </div>',
|
||||
');',
|
||||
].join('\n'),
|
||||
);
|
||||
// .ts も content に載っている(ui/src/lib/workspaceDirs.ts がこの形)。
|
||||
write('ui/src/roles.ts', `export const badge = 'bg-slate-100 text-slate-600 ${T(300)}';\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('check-dark-text', () => {
|
||||
it('dark: 付きの text-slate だけを拾う(素の指定・別の色・別の性質は拾わない)', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
|
||||
const found = JSON.parse(readFileSync(baseline(), 'utf8')) as {
|
||||
total: number;
|
||||
files: number;
|
||||
entries: { file: string; class: string; count: number }[];
|
||||
};
|
||||
const classes = found.entries.map((e) => e.class).sort();
|
||||
// 変化形は前にも後ろにも付いたまま数える。素の text-slate-*、dark:bg-slate-*、
|
||||
// dark:text-emerald-* は 1 件も混じらない。
|
||||
expect(classes).toEqual(
|
||||
[T(200), `group-hover:${T(400)}`, T(300, 'hover:'), T(300)].sort(),
|
||||
);
|
||||
expect(found.total).toBe(4);
|
||||
expect(found.entries.some((e) => e.file.endsWith('index.html'))).toBe(true);
|
||||
expect(found.entries.some((e) => e.file.endsWith('roles.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('控えと同じなら通る', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
const check = run();
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('no new ones');
|
||||
});
|
||||
|
||||
it('新しいファイルに 1 つ足すと落ちる', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write('ui/src/New.tsx', `export const New = () => <div className="text-slate-700 ${T(200)}" />;\n`);
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain(T(200));
|
||||
expect(check.stderr).toContain('New.tsx');
|
||||
// 落ちたときのメッセージが、なぜ駄目かと直し方を持っていること。
|
||||
expect(check.stderr).toContain('INVERTS');
|
||||
expect(check.stderr).toContain('-> text-slate-700');
|
||||
});
|
||||
|
||||
it('同じファイルで件数が増えても落ちる', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write('ui/index.html', `<div class="${T(200)}"></div>\n<div class="${T(200)}"></div>\n`);
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain('baseline allows 1, found 2');
|
||||
});
|
||||
|
||||
it('控えにある分を直したら、通したうえで控えの更新を促す', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write('ui/src/roles.ts', "export const badge = 'bg-slate-100 text-slate-600';\n");
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('-- --update');
|
||||
});
|
||||
|
||||
it('控えが無ければ落ちる(黙って通さない)', () => {
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain('--update');
|
||||
});
|
||||
|
||||
it('このリポジトリ自身も控えと一致している', () => {
|
||||
if (!existsSync(join(repoRoot, 'ui', 'node_modules'))) return; // 依存未導入なら見送る
|
||||
const check = spawnSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8' });
|
||||
expect(check.stderr + check.stdout).toContain('OK');
|
||||
expect(check.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { unlink, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const ui = join(root, 'ui');
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
process.chdir(ui);
|
||||
const require = createRequire(join(ui, 'package.json'));
|
||||
const { createServer: createViteServer } = await import(pathToFileURL(require.resolve('vite')).href);
|
||||
const { chromium } = require('playwright');
|
||||
const fixture = `/tmp/execution-conversations-${process.pid}.js`;
|
||||
let vite, server, browser;
|
||||
try {
|
||||
await writeFile(fixture, `
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '${ui}/src/index.css';
|
||||
import i18n from '${ui}/src/i18n/index.ts';
|
||||
import { TaskExecutionsProvider } from '${ui}/src/hooks/useTaskExecutions.ts';
|
||||
import { ExecutionsTab } from '${ui}/src/components/detail/tabs/ExecutionsTab.tsx';
|
||||
import { useVisibleDetailTabs } from '${ui}/src/components/detail/detailTabs.ts';
|
||||
import { SpaceChatRail } from '${ui}/src/components/spaces/SpaceChatRail.tsx';
|
||||
function App() {
|
||||
const [kind, setKind] = React.useState('delegate');
|
||||
const tabs = useVisibleDetailTabs(1).filter(t => ['delegates','subtasks'].includes(t.id));
|
||||
return React.createElement('div', {style:{display:'flex',width:'100%',minWidth:0}},
|
||||
React.createElement('main', {style:{flex:1,minWidth:0,padding:12}}, React.createElement(ExecutionsTab,{key:kind,taskId:1,kind})),
|
||||
React.createElement(SpaceChatRail,{items:tabs,activeId:kind==='delegate'?'delegates':'subtasks',paneOpen:true,onSelect:id=>setKind(id==='delegates'?'delegate':'subtask'),ariaLabel:'Executions',renderLabel:t=>i18n.t(t.labelKey,{ns:'detail'}),appearClass:()=>''}));
|
||||
}
|
||||
window.setLanguage = async lang => {await i18n.changeLanguage(lang); render();};
|
||||
const tree = createRoot(document.getElementById('root'));
|
||||
const client = new QueryClient({defaultOptions:{queries:{retry:false}}});
|
||||
function render(){ tree.render(React.createElement(QueryClientProvider,{client},React.createElement(TaskExecutionsProvider,{taskId:1},React.createElement(App)))); }
|
||||
render();
|
||||
`);
|
||||
vite = await createViteServer({ root: ui, appType: 'custom', logLevel: 'error', server: { middlewareMode: true, hmr: false, fs: { allow: [root, '/tmp'] } } });
|
||||
const executions = [
|
||||
...Array.from({ length: 80 }, (_, i) => ({ id: `delegate:j:d${i}`, kind: 'delegate', jobId: 'j', rootJobId: 'j', title: `Research ${i + 1}`, status: 'success', createdAt: '2026-09-08T00:00:00Z' })),
|
||||
{ id: 'subtask:k', kind: 'subtask', jobId: 'k', rootJobId: 'j', title: 'Implementation', status: 'succeeded', createdAt: '2026-09-08T00:01:00Z' },
|
||||
];
|
||||
const conversationRequests = [];
|
||||
server = createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/api/')) {
|
||||
let payload = { available: false, active: false };
|
||||
if (req.url.endsWith('/executions')) payload = { executions, truncated: false };
|
||||
if (req.url.endsWith('/conversation')) {
|
||||
conversationRequests.push(req.url);
|
||||
payload = { source: 'history', truncated: false, messages: [
|
||||
{ role: 'user', content: 'Inspect the example files.' },
|
||||
{ role: 'assistant', content: '**Readable result**', thinking: 'Recorded thinking details', llm: { durationMs: 1800, promptTokens: 400, completionTokens: 60 }, toolCalls: [{ id: 'call1', name: 'Read', arguments: '{"path":"sample.txt"}' }] },
|
||||
{ role: 'tool', toolCallId: 'call1', content: 'Recorded tool result', isError: true, durationMs: 1234, cacheHit: false },
|
||||
] };
|
||||
}
|
||||
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(payload)); return;
|
||||
}
|
||||
if (req.url === '/') {
|
||||
res.setHeader('content-type', 'text/html');
|
||||
res.end(await vite.transformIndexHtml('/', `<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/@fs${fixture}"></script></body></html>`)); return;
|
||||
}
|
||||
vite.middlewares(req, res);
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
const page = await browser.newPage({ viewport: { width: 600, height: 700 } });
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||
await page.getByText('Readable result', { exact: true }).waitFor();
|
||||
assert.equal(conversationRequests.length, 1, 'only the selected conversation is fetched');
|
||||
for (const lang of ['ja', 'en']) {
|
||||
await page.evaluate(lang => window.setLanguage(lang), lang);
|
||||
for (const width of [320, 600, 900]) {
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
for (const kind of ['delegates', 'subtasks']) {
|
||||
await page.getByTestId(`space-chat-tab-${kind}`).click();
|
||||
await page.getByText('Readable result', { exact: true }).waitFor();
|
||||
const panel = page.getByTestId(kind === 'delegates' ? 'delegate-conversations' : 'subtask-conversations');
|
||||
assert.equal(await panel.getByRole('tablist').getAttribute('aria-orientation'), 'vertical');
|
||||
if (kind === 'delegates') {
|
||||
const tab = page.getByRole('tab', { name: /^Research 1 / });
|
||||
await tab.click(); await tab.focus(); await page.keyboard.press('End');
|
||||
assert.equal(await page.getByRole('tab', { name: /^Research 80 / }).getAttribute('aria-selected'), 'true');
|
||||
await page.getByRole('searchbox').fill('Research 79');
|
||||
assert.equal(await panel.getByRole('tab').count(), 1);
|
||||
await page.getByRole('tab', { name: /^Research 79 / }).click();
|
||||
await page.getByRole('searchbox').fill('');
|
||||
assert.equal(await page.getByRole('tab', { name: /^Research 79 / }).getAttribute('aria-selected'), 'true');
|
||||
}
|
||||
await page.getByText('Readable result', { exact: true }).waitFor();
|
||||
const thinking = page.getByRole('button', { name: lang === 'ja' ? '思考' : 'Thinking', exact: true });
|
||||
await thinking.click();
|
||||
await page.getByText('Recorded thinking details', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByText('1.8s', { exact: true }).count(), 1);
|
||||
assert.equal(await page.getByText('Recorded tool result', { exact: true }).count(), 0);
|
||||
await page.getByRole('button', { name: /Read.*sample/ }).click();
|
||||
await page.getByText('Recorded tool result', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: lang === 'ja' ? '実行一覧を隠す' : 'Hide execution list' }).click();
|
||||
assert.equal(await panel.getByRole('tablist').count(), 0);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${lang}/${width}/${kind}: page overflow`);
|
||||
await page.getByRole('button', { name: lang === 'ja' ? '実行一覧を表示' : 'Show execution list' }).click();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${lang}/${width}/${kind}: list overflow`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (process.env.SCREENSHOT_PATH) await page.screenshot({ path: process.env.SCREENSHOT_PATH });
|
||||
console.log('execution conversations: 12 language/width/kind cases, 80-run search, keyboard, thinking, tool failure and collapse passed');
|
||||
} finally {
|
||||
await browser?.close(); await vite?.close(); server?.close(); await unlink(fixture).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// scripts/check-tailwind-opacity.mjs
|
||||
//
|
||||
// Guards against Tailwind opacity modifiers that silently produce nothing.
|
||||
//
|
||||
// ui/tailwind.config.js maps our custom colors straight onto CSS variables
|
||||
// (`slate: { 900: 'var(--slate-900)' }`). A bare `var(...)` has no slot for an
|
||||
// alpha channel, so when Tailwind is asked for `bg-slate-900/50` it cannot
|
||||
// build the color and drops the whole declaration. No error, no warning: the
|
||||
// class name stays in the DOM, typecheck passes, tests pass, and the element
|
||||
// simply renders with no background. The only way to notice is to grep the
|
||||
// built CSS.
|
||||
//
|
||||
// This check reads the config, works out which color names lack an alpha slot,
|
||||
// and scans the sources Tailwind is configured to scan. Existing hits are
|
||||
// recorded in a baseline file so the check only fails on *new* ones.
|
||||
//
|
||||
// The scan is plain text, like Tailwind's own class extractor: a class name
|
||||
// written inside a comment counts too. Mention such a class as `bg-slate-900`
|
||||
// plus `/50` rather than as one token if you need to name it in prose.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-tailwind-opacity.mjs # verify against baseline
|
||||
// node scripts/check-tailwind-opacity.mjs --update # rewrite the baseline
|
||||
// node scripts/check-tailwind-opacity.mjs --ui <dir> --baseline <file>
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, relative, join, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
|
||||
/** `--flag value` / `--flag=value`; returns undefined when absent. */
|
||||
function argValue(flag) {
|
||||
const argv = process.argv.slice(2);
|
||||
const i = argv.indexOf(`--${flag}`);
|
||||
if (i !== -1 && argv[i + 1]) return argv[i + 1];
|
||||
const inline = argv.find((a) => a.startsWith(`--${flag}=`));
|
||||
return inline ? inline.slice(flag.length + 3) : undefined;
|
||||
}
|
||||
|
||||
// `--ui` / `--baseline` exist so the check can be exercised against a fixture
|
||||
// tree (see scripts/check-tailwind-opacity.test.ts). Normal runs take neither.
|
||||
const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui');
|
||||
const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js');
|
||||
const BASELINE_PATH = argValue('baseline')
|
||||
? resolve(ROOT, argValue('baseline'))
|
||||
: resolve(__dirname, 'tailwind-opacity-baseline.json');
|
||||
|
||||
const UPDATE = process.argv.includes('--update');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Which colors have no place to put an opacity value?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A color value can carry an opacity modifier when it is a function
|
||||
* (`({ opacityValue }) => ...`) or a string with an `<alpha-value>`
|
||||
* placeholder. A plain string is fine too *as long as* Tailwind can parse it
|
||||
* as a color — hex, rgb(), hsl(). A bare `var(--x)` cannot be parsed, so the
|
||||
* utility is dropped.
|
||||
*/
|
||||
function lacksAlphaSlot(value) {
|
||||
if (typeof value === 'function') return false;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value.includes('<alpha-value>')) return false;
|
||||
return value.includes('var(');
|
||||
}
|
||||
|
||||
/** Walk theme.extend.colors and return the class fragments that are unsafe. */
|
||||
function collectUnsafeColorNames(colors, prefix = []) {
|
||||
const names = [];
|
||||
for (const [key, value] of Object.entries(colors ?? {})) {
|
||||
const path = key === 'DEFAULT' ? prefix : [...prefix, key];
|
||||
if (value && typeof value === 'object') {
|
||||
names.push(...collectUnsafeColorNames(value, path));
|
||||
} else if (lacksAlphaSlot(value)) {
|
||||
names.push(path.join('-'));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = (await import(pathToFileURL(CONFIG_PATH).href)).default;
|
||||
} catch (err) {
|
||||
console.error(`tailwind-opacity: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``);
|
||||
console.error(String(err?.message ?? err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const unsafeNames = collectUnsafeColorNames(config?.theme?.extend?.colors);
|
||||
if (unsafeNames.length === 0) {
|
||||
// Either the config stopped using bare `var()` colors (the real fix) or the
|
||||
// shape changed under us. Either way, say so rather than passing silently.
|
||||
console.log('tailwind-opacity: no opacity-less colors in tailwind.config.js — nothing to guard.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Longest first so `accent-soft` wins over `accent`.
|
||||
unsafeNames.sort((a, b) => b.length - a.length);
|
||||
|
||||
// A utility token ends with `-<color>/<alpha>`; `<alpha>` is a number or an
|
||||
// arbitrary value. Variants (`dark:`, `hover:`, `group-hover:`) sit in front
|
||||
// and are matched by the leading run of class characters.
|
||||
const CLASS_RE = new RegExp(
|
||||
String.raw`(?<![\w./-])((?:[a-z][\w.:-]*?)-(?:${unsafeNames.map(escapeRe).join('|')})/(?:\d+(?:\.\d+)?|\[[^\]\s]+\]))`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Which files does Tailwind scan?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Turn a Tailwind content glob into { dir, test } we can walk. */
|
||||
function compileGlob(pattern) {
|
||||
const clean = pattern.replace(/^\.\//, '');
|
||||
const parts = clean.split('/');
|
||||
const firstMagic = parts.findIndex((p) => /[*?{[]/.test(p));
|
||||
const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/');
|
||||
const rx = globToRegExp(clean);
|
||||
return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) };
|
||||
}
|
||||
|
||||
/** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */
|
||||
function globToRegExp(glob) {
|
||||
let out = '';
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
i += 1;
|
||||
if (glob[i + 1] === '/') {
|
||||
i += 1;
|
||||
out += '(?:[^/]+/)*';
|
||||
} else {
|
||||
out += '.*';
|
||||
}
|
||||
} else {
|
||||
out += '[^/]*';
|
||||
}
|
||||
} else if (c === '?') {
|
||||
out += '[^/]';
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) {
|
||||
out += escapeRe(c);
|
||||
} else {
|
||||
out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`;
|
||||
i = end;
|
||||
}
|
||||
} else {
|
||||
out += escapeRe(c);
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${out}$`);
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!existsSync(dir)) return out;
|
||||
if (!statSync(dir).isDirectory()) {
|
||||
out.push(dir);
|
||||
return out;
|
||||
}
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.isFile()) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter(
|
||||
(p) => typeof p === 'string',
|
||||
);
|
||||
if (contentPatterns.length === 0) {
|
||||
console.error('tailwind-opacity: tailwind.config.js has no string `content` globs to scan.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = new Set();
|
||||
for (const pattern of contentPatterns) {
|
||||
const { dir, test } = compileGlob(pattern);
|
||||
for (const file of walk(dir)) {
|
||||
const relToUi = relative(UI_DIR, file).split(sep).join('/');
|
||||
if (test(relToUi)) files.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** key -> { file, class, count, lines[] } */
|
||||
const found = new Map();
|
||||
for (const file of [...files].sort()) {
|
||||
const relPath = relative(ROOT, file).split(sep).join('/');
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
for (const match of line.matchAll(CLASS_RE)) {
|
||||
const cls = match[1];
|
||||
const key = `${relPath} ${cls}`;
|
||||
const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] };
|
||||
hit.count += 1;
|
||||
hit.lines.push(i + 1);
|
||||
found.set(key, hit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const entries = [...found.values()]
|
||||
.sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class))
|
||||
.map(({ file, class: cls, count }) => ({ file, class: cls, count }));
|
||||
const total = entries.reduce((n, e) => n + e.count, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Compare with the baseline (or write it)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (UPDATE) {
|
||||
const payload = {
|
||||
_comment: [
|
||||
'Known uses of a Tailwind opacity modifier on a color that has no alpha slot',
|
||||
'(see scripts/check-tailwind-opacity.mjs). These produce no CSS at all.',
|
||||
'This file exists so the check fails on NEW ones only. Do not add to it by',
|
||||
'hand: fix the class, then run `npm run check:tailwind-opacity -- --update`.',
|
||||
],
|
||||
total,
|
||||
files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size,
|
||||
classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size,
|
||||
entries,
|
||||
};
|
||||
writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
console.log(`tailwind-opacity: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(BASELINE_PATH)) {
|
||||
console.error(`tailwind-opacity: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:tailwind-opacity -- --update\`.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count]));
|
||||
|
||||
const added = [];
|
||||
for (const [key, hit] of found) {
|
||||
const allowed = baselineCounts.get(key) ?? 0;
|
||||
if (hit.count > allowed) added.push({ ...hit, allowed });
|
||||
}
|
||||
|
||||
const removed = [];
|
||||
for (const [key, count] of baselineCounts) {
|
||||
const current = found.get(key)?.count ?? 0;
|
||||
if (current < count) removed.push({ key, count, current });
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
console.error('tailwind-opacity: opacity modifier on a color that cannot carry one — Tailwind drops these rules silently.\n');
|
||||
for (const hit of added) {
|
||||
const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', ');
|
||||
const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : '';
|
||||
console.error(` ${hit.class}${extra}\n ${where}`);
|
||||
}
|
||||
console.error(`\n${added.length} new use(s). These colors are CSS variables in ui/tailwind.config.js, so \`/50\` style`);
|
||||
console.error('modifiers compile to nothing. Write the color with its alpha inline instead, e.g.');
|
||||
console.error(' bg-slate-900/50 -> bg-[rgb(15_23_42_/_0.5)]');
|
||||
console.error('Colors without an alpha slot: ' + [...unsafeNames].sort().join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
console.log(`tailwind-opacity: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`);
|
||||
console.log(' npm run check:tailwind-opacity -- --update');
|
||||
for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`);
|
||||
}
|
||||
|
||||
console.log(`tailwind-opacity: OK — ${total} known use(s), no new ones.`);
|
||||
@@ -0,0 +1,151 @@
|
||||
// scripts/check-tailwind-opacity.mjs が本当に効くことを守るテスト (#1377)。
|
||||
//
|
||||
// 守る対象は「いま何件あるか」ではなく、**効かない組み合わせを書いたら赤くなる
|
||||
// 経路が残っていること**。控え(baseline)方式は放っておくと「全部控えに入れて
|
||||
// 常に緑」になりやすいので、増えたら落ちる・減ったら知らせる、の両方を固定する。
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const script = join(repoRoot, 'scripts', 'check-tailwind-opacity.mjs');
|
||||
|
||||
/** 不透明度の置き場がある色(黒・白・<alpha-value> 付き)と、無い色(var())。 */
|
||||
const FIXTURE_CONFIG = `export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: 'var(--brand-primary, #18181b)',
|
||||
'accent-soft': 'var(--brand-primary-soft, #f4f4f5)',
|
||||
themed: 'rgb(var(--themed) / <alpha-value>)',
|
||||
plain: '#123456',
|
||||
slate: { 900: 'var(--slate-900)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
let dir: string;
|
||||
const ui = () => join(dir, 'ui');
|
||||
const baseline = () => join(dir, 'baseline.json');
|
||||
|
||||
function write(rel: string, body: string) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, body);
|
||||
}
|
||||
|
||||
function run(...extra: string[]) {
|
||||
return spawnSync(process.execPath, [script, '--ui', ui(), '--baseline', baseline(), ...extra], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'tw-opacity-'));
|
||||
// 見つけ先の tailwind.config.js を ESM として読ませる。
|
||||
write('ui/package.json', '{ "type": "module" }\n');
|
||||
write('ui/tailwind.config.js', FIXTURE_CONFIG);
|
||||
// index.html も content に載っているので、こちらの 1 件も拾われる。
|
||||
write('ui/index.html', '<div class="bg-plain/50 bg-accent/5"></div>\n');
|
||||
write(
|
||||
'ui/src/Panel.tsx',
|
||||
[
|
||||
'export const Panel = () => (',
|
||||
' <div className="bg-slate-900/50 dark:bg-accent-soft/80 border-accent/30">',
|
||||
' <span className="bg-black/40 bg-white/50 bg-themed/60 bg-plain/25 bg-[rgb(15_23_42_/_0.5)]" />',
|
||||
' </div>',
|
||||
');',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('check-tailwind-opacity', () => {
|
||||
it('不透明度の置き場が無い色だけを拾う(黒・白・<alpha-value>・具体値は拾わない)', () => {
|
||||
const written = run('--update');
|
||||
expect(written.status).toBe(0);
|
||||
|
||||
const found = JSON.parse(readFileSync(baseline(), 'utf8')) as {
|
||||
total: number;
|
||||
entries: { file: string; class: string; count: number }[];
|
||||
};
|
||||
const classes = found.entries.map((e) => e.class).sort();
|
||||
// 変化形(dark: 等)は付いたまま数える。安全な色(黒・白・<alpha-value>・
|
||||
// 具体値・すでに直した arbitrary value)は 1 件も混じらない。
|
||||
expect(classes).toEqual(
|
||||
['bg-accent/5', 'dark:bg-accent-soft/80', 'bg-slate-900/50', 'border-accent/30'].sort(),
|
||||
);
|
||||
expect(found.total).toBe(4);
|
||||
expect(found.entries.some((e) => e.file.endsWith('index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('控えと同じなら通る', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
const check = run();
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('no new ones');
|
||||
});
|
||||
|
||||
it('効かない組み合わせを 1 つ足すと落ちる', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write('ui/src/New.tsx', 'export const New = () => <div className="bg-slate-900/25" />;\n');
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain('bg-slate-900/25');
|
||||
expect(check.stderr).toContain('New.tsx');
|
||||
});
|
||||
|
||||
it('同じファイルで件数が増えても落ちる', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write(
|
||||
'ui/src/Panel.tsx',
|
||||
[
|
||||
'export const Panel = () => (',
|
||||
' <div className="bg-slate-900/50 dark:bg-accent-soft/80 border-accent/30">',
|
||||
' <span className="bg-slate-900/50" />',
|
||||
' </div>',
|
||||
');',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain('baseline allows 1, found 2');
|
||||
});
|
||||
|
||||
it('控えにある分を直したら、通したうえで控えの更新を促す', () => {
|
||||
expect(run('--update').status).toBe(0);
|
||||
write(
|
||||
'ui/src/Panel.tsx',
|
||||
'export const Panel = () => <div className="bg-[rgb(15_23_42_/_0.5)]" />;\n',
|
||||
);
|
||||
|
||||
const check = run();
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('-- --update');
|
||||
});
|
||||
|
||||
it('控えが無ければ落ちる(黙って通さない)', () => {
|
||||
const check = run();
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.stderr).toContain('--update');
|
||||
});
|
||||
|
||||
it('このリポジトリ自身も控えと一致している', () => {
|
||||
if (!existsSync(join(repoRoot, 'ui', 'node_modules'))) return; // 依存未導入なら見送る
|
||||
const check = spawnSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8' });
|
||||
expect(check.stderr + check.stdout).toContain('OK');
|
||||
expect(check.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { unlink, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const uiRoot = join(repoRoot, 'ui');
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
process.chdir(uiRoot);
|
||||
const requireFromUi = createRequire(join(uiRoot, 'package.json'));
|
||||
const { createServer: createViteServer } = await import(pathToFileURL(requireFromUi.resolve('vite')).href);
|
||||
const playwrightModule = await import(pathToFileURL(requireFromUi.resolve('playwright')).href);
|
||||
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
|
||||
const fixturePath = `/tmp/task-files-toolbar-${process.pid}.js`;
|
||||
|
||||
await writeFile(fixturePath, `
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '${uiRoot}/src/index.css';
|
||||
import i18n from '${uiRoot}/src/i18n/index.ts';
|
||||
import { FileBrowser } from '${uiRoot}/src/components/files/FileBrowser.tsx';
|
||||
const entries = [
|
||||
{ name: 'report.pdf', path: 'report.pdf', kind: 'file', size: 10, modifiedAt: '2026-08-01T00:00:00Z' },
|
||||
{ name: 'data.csv', path: 'data.csv', kind: 'file', size: 10, modifiedAt: '2026-08-01T00:00:00Z' },
|
||||
];
|
||||
const management = {
|
||||
canManage: true, writableSection: true, selected: new Set(),
|
||||
toggleSelect() {}, toggleSelectAll() {}, upload() {}, remove() {}, download() {},
|
||||
isUploading: false, isDeleting: false, isDownloading: false, message: null,
|
||||
};
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
window.__renderToolbar = (managed) => root.render(React.createElement(FileBrowser, {
|
||||
section: 'output', currentPath: '', entries, pathSegments: [], taskId: 42,
|
||||
onSectionChange(section) { window.__selectedSection = section; },
|
||||
onNavigate() {}, onPreview() {}, onRefresh() {},
|
||||
management: managed ? management : undefined,
|
||||
}));
|
||||
window.__setToolbarLanguage = (language) => i18n.changeLanguage(language);
|
||||
window.__renderToolbar(true);
|
||||
`);
|
||||
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
fs: { allow: [repoRoot, '/tmp', realpathSync(join(uiRoot, 'node_modules'))] },
|
||||
},
|
||||
});
|
||||
const server = createHttpServer(async (req, res) => {
|
||||
if (req.url === '/') {
|
||||
const html = await vite.transformIndexHtml('/', `<!doctype html><html><head><meta charset="utf-8"></head><body><main id="case" style="width:720px"><div id="root"></div></main><script type="module" src="/@fs${fixturePath}"></script></body></html>`);
|
||||
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
vite.middlewares(req, res);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Fixture server did not bind TCP');
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`);
|
||||
await page.getByTestId('task-file-origin-toggle').waitFor();
|
||||
|
||||
for (const language of ['ja', 'en']) {
|
||||
await page.evaluate(async lang => { await window.__setToolbarLanguage(lang); }, language);
|
||||
for (const managed of [true, false]) {
|
||||
await page.evaluate(value => window.__renderToolbar(value), managed);
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
for (const width of [699, 700, 701, 720, 721, 740, 620, 461, 460, 400, 320]) {
|
||||
await page.locator('#case').evaluate((element, value) => { element.style.width = `${value}px`; }, width);
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const layout = await page.evaluate(() => {
|
||||
const rect = element => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height };
|
||||
};
|
||||
const tabs = document.querySelector('[data-testid="task-file-sections"]');
|
||||
const actions = document.querySelector('.file-toolbar-actions');
|
||||
if (!tabs || !actions) throw new Error('Toolbar regions missing');
|
||||
const tabRect = rect(tabs);
|
||||
const actionRect = rect(actions);
|
||||
const buttons = [...tabs.querySelectorAll('button')].map(button => {
|
||||
const buttonRect = rect(button);
|
||||
const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 };
|
||||
return {
|
||||
id: button.dataset.testid,
|
||||
rect: buttonRect,
|
||||
hit: document.elementFromPoint(center.x, center.y)?.closest('[data-testid]')?.getAttribute('data-testid') ?? null,
|
||||
inside: buttonRect.left >= tabRect.left && buttonRect.right <= tabRect.right,
|
||||
};
|
||||
});
|
||||
return { tabs: tabRect, actions: actionRect, buttons };
|
||||
});
|
||||
|
||||
const context = `${language}, managed=${managed}, width=${width}`;
|
||||
if (width <= 720) {
|
||||
assert.ok(layout.actions.top >= layout.tabs.bottom, `actions must occupy a separate row: ${context}`);
|
||||
} else {
|
||||
assert.ok(layout.actions.top < layout.tabs.bottom, `wide toolbar must remain one row: ${context}`);
|
||||
}
|
||||
for (const button of layout.buttons) {
|
||||
assert.ok(button.inside, `${button.id} is clipped outside the section region: ${context}; ${JSON.stringify({ button: button.rect, tabs: layout.tabs })}`);
|
||||
assert.equal(button.hit, button.id, `${button.id} is not the hit target: ${context}`);
|
||||
await page.evaluate(() => { window.__selectedSection = null; });
|
||||
await page.getByTestId(button.id).click();
|
||||
assert.equal(await page.evaluate(() => window.__selectedSection), button.id.replace('task-file-section-', ''), `${button.id} click did not select: ${context}`);
|
||||
}
|
||||
const origin = page.getByTestId('task-file-origin-toggle');
|
||||
await origin.click();
|
||||
assert.equal(await origin.getAttribute('aria-pressed'), 'true', `origin toggle did not activate: ${context}`);
|
||||
await origin.click();
|
||||
assert.equal(await origin.getAttribute('aria-pressed'), 'false', `origin toggle did not reset: ${context}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('task-files-toolbar: 44 width/language/permission cases passed');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await vite.close();
|
||||
server.close();
|
||||
await unlink(fixturePath).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Local-only integration check: actual browser audio processing and AAO relay, synthetic ASR and translation servers.
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { once } from 'node:events';
|
||||
import assert from 'node:assert/strict';
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require=createRequire(root+'/package.json');
|
||||
const express=require('express');
|
||||
const Database=require('better-sqlite3');
|
||||
const {WebSocketServer}=require('ws');
|
||||
const {chromium}=require('playwright');
|
||||
const {createTranscriptionApi}=await import(root+'/src/bridge/transcription-api.ts');
|
||||
const {attachTranscriptionWs}=await import(root+'/src/bridge/transcription-ws.ts');
|
||||
const upstream=new WebSocketServer({port:0}); await once(upstream,'listening');
|
||||
let audioFrames=0, finals=0;
|
||||
upstream.on('connection', ws=>{
|
||||
let n=0;
|
||||
ws.send('{"type":"ready"}');
|
||||
ws.on('message',(data,binary)=>{
|
||||
if(binary){audioFrames++;n++; if(n%3===0) ws.send(JSON.stringify({type:'transcript',text:`議題は AAO の文字起こし機能です。途中結果 ${n}`,is_final:false,segment_id:'session',segments:[]}));}
|
||||
else {const m=JSON.parse(data.toString());if(m.type==='config')ws.send('{"type":"config_ack"}');if(m.type==='end'){finals++;ws.send(JSON.stringify({type:'transcript',text:'議題は AAO の文字起こし機能です。次回までに動作を確認します。',is_final:true,segment_id:'session',segments:[{id:'s1',start:0,end:2,speaker:'1',text:'議題は AAO の文字起こし機能です。',timestamp_source:'model'}]}));}}
|
||||
});
|
||||
});
|
||||
const translatedRequests=[];
|
||||
const translationApp=express();translationApp.use(express.json());
|
||||
translationApp.post('/v1/chat/completions',(req,res)=>{
|
||||
const content=req.body.messages?.[0]?.content;
|
||||
assert.equal(req.body.model,'test-translategemma');
|
||||
assert.equal(typeof content,'string');
|
||||
assert(content.startsWith('<<<source>>>ja<<<target>>>en<<<text>>>'));
|
||||
const original=content.split('<<<text>>>')[1]; translatedRequests.push(original);
|
||||
setTimeout(()=>res.json({choices:[{finish_reason:'stop',message:{content:`Translated: ${original}`}}]}),100);
|
||||
});
|
||||
const translationServer=translationApp.listen(0,'127.0.0.1');await once(translationServer,'listening');
|
||||
const translationEndpoint=`http://127.0.0.1:${translationServer.address().port}/v1`;
|
||||
const tools={speechStreamingUrl:`ws://127.0.0.1:${upstream.address().port}/v1/audio/stream`,speechStreamingTimeout:10};
|
||||
const configuredEndpoint=tools.speechStreamingUrl; tools.speechStreamingUrl='';
|
||||
const db=new Database(':memory:');
|
||||
const fixtureRoot=mkdtempSync(resolve(tmpdir(),'aao-chat-transcription-'));
|
||||
const timestamp=new Date().toISOString();
|
||||
const space={id:'browser-space',kind:'personal',title:'Browser Workspace',description:'',ownerId:'browser-test',visibility:'private',visibilityScopeOrgId:null,status:'open',brandColor:null,workspaceDir:fixtureRoot,createdAt:timestamp,updatedAt:timestamp,myRole:'owner'};
|
||||
const tasks=[1,2].map(id=>({id,title:id===1?'会議 A':'会議 B',body:'Browser transcription check',pieceName:'chat',profile:'default',outputFormat:'text',askPolicy:'auto',priority:'normal',state:'succeeded',workspacePath:resolve(fixtureRoot,`task-${id}`),ownerId:'browser-test',ownerName:'Browser Test',visibility:'private',spaceId:space.id,createdAt:timestamp,updatedAt:timestamp,latestJob:null,subtasks:[],tags:[],allowedTools:[]}));
|
||||
for(const task of tasks) mkdirSync(task.workspacePath,{recursive:true});
|
||||
const provenance=[];
|
||||
const repo={getLatestJobForIssue:async()=>null,getLocalTask:async id=>tasks.find(task=>task.id===id)??null,getSpace:()=>space,getSpaceMemberRole:()=> 'owner',userCanViewSpace:()=>true,recordProvenance:entry=>provenance.push(entry)};
|
||||
|
||||
const app=express();
|
||||
app.use((req,res,next)=>{req.user={id:'browser-test',role:'admin',email:'[email protected]',name:'Browser Test'};next();});
|
||||
app.use('/api/transcription',createTranscriptionApi({db,repo,authActive:true,getTools:()=>tools}));
|
||||
app.use(express.json());
|
||||
app.get('/api/auth/me',(req,res)=>res.json(req.user));
|
||||
app.get('/api/setup/status',(_req,res)=>res.json({needsSetup:false,authActive:true}));
|
||||
app.get('/api/config',(_req,res)=>res.json({config:{configVersion:2,tools,storage:{},llm:{workers:[]}},overriddenByEnv:{}}));
|
||||
app.put('/api/config',(req,res)=>{Object.assign(tools,req.body.tools);res.json({ok:true});});
|
||||
app.get('/api/local/spaces',(_req,res)=>res.json([space]));
|
||||
app.get('/api/local/spaces/:id',(_req,res)=>res.json(space));
|
||||
app.get('/api/local/spaces/:id/members',(_req,res)=>res.json([{userId:'browser-test',name:'Browser Test',role:'owner',isOwner:true}]));
|
||||
app.get('/api/local/tasks',(_req,res)=>res.json({tasks}));
|
||||
app.get('/api/local/tasks/:id', (req,res)=>res.json({task:tasks.find(task=>task.id===Number(req.params.id))}));
|
||||
app.get('/api/local/tasks/:id/overview-metrics',(_req,res)=>res.json({metrics:{collecting:false,toolCalls:null,compactions:null,activeDurationMs:null,workerId:null,workerName:null,model:null,decodeTps:null,decodeTpsMeasuredAt:null,jobId:null,jobStatus:null}}));
|
||||
app.get('/api/local/tasks/:id/comments',(_req,res)=>res.json({comments:[]}));
|
||||
app.get('/api/local/tasks/:id/movement-history',(_req,res)=>res.json({events:[]}));
|
||||
app.get('/api/local/tasks/:id/executions',(_req,res)=>res.json({executions:[],active:false}));
|
||||
app.get('/api/local/tasks/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
|
||||
app.get('/api/local/spaces/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
|
||||
app.get('/api/local/tasks/:id/console/status',(_req,res)=>res.json({active:false}));
|
||||
app.get('/api/local/browser/sessions/task-session/:id',(_req,res)=>res.json({available:false}));
|
||||
|
||||
app.get('/api/pieces',(_req,res)=>res.json({pieces:[]}));
|
||||
app.get('/api/pieces/:name',(req,res)=>res.json({piece:{name:req.params.name,description:'',movements:[],max_movements:1,initial_movement:''},source:'builtin'}));
|
||||
app.get('/api/users/me/pets',(_req,res)=>res.json({pets:[],settings:{enabled:false,activePetId:null,size:48,position:'bottom-right',sound:false,reducedMotion:true,toolSparkEnabled:false,workerPets:{}}}));
|
||||
app.get('/api/notifications/capabilities',(_req,res)=>res.json({}));
|
||||
app.use('/api',(_req,res)=>res.status(404).json({error:'Not included in the local browser fixture'}));
|
||||
app.use('/ui',express.static(root+'/ui/dist'));
|
||||
app.get('/ui/*',(_req,res)=>res.sendFile(root+'/ui/dist/index.html'));
|
||||
const server=createServer(app);
|
||||
const stop=attachTranscriptionWs(server,{authActive:true,authenticateUpgrade:async()=>({id:'browser-test',role:'admin'}),getTools:()=>tools});
|
||||
server.listen(0,'127.0.0.1');await once(server,'listening');
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
let browser, page;
|
||||
const errors=[];
|
||||
try{
|
||||
browser=await chromium.launch({headless:true,args:['--no-sandbox','--use-fake-device-for-media-stream','--use-fake-ui-for-media-stream','--autoplay-policy=no-user-gesture-required']});
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000},permissions:['microphone']});
|
||||
await context.addInitScript(()=>{
|
||||
localStorage.setItem('maestro.lang','ja');
|
||||
navigator.mediaDevices.getDisplayMedia=async()=>{
|
||||
const ctx=new AudioContext(); const oscillator=ctx.createOscillator(); const output=ctx.createMediaStreamDestination();oscillator.connect(output);oscillator.start();
|
||||
window.__desktopAudioContext=ctx; return output.stream;
|
||||
};
|
||||
});
|
||||
page=await context.newPage(); page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(base+'/ui/?page=transcription');
|
||||
await page.getByText(/文字起こしはチャット右側/).waitFor();
|
||||
await page.goto(base+'/ui/?space=browser-space&chat=1&tab=transcription');
|
||||
const dockA=page.locator('[data-transcription-task="1"]');
|
||||
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
|
||||
await page.getByRole('button',{name:'設定を開く',exact:true}).click();
|
||||
await page.getByPlaceholder('ws://gpu-server:8000/v1/audio/stream').fill(configuredEndpoint);
|
||||
await page.locator('#translation-server-url').fill(translationEndpoint);
|
||||
await page.locator('#translation-model').fill('test-translategemma');
|
||||
const savedConfig=page.waitForResponse(r=>r.url().endsWith('/api/config') && r.request().method()==='PUT');
|
||||
await page.getByRole('button',{name:/保存して反映|Save and apply/i}).click();
|
||||
assert.equal((await savedConfig).status(),200);
|
||||
await page.getByRole('button',{name:/^(ワークスペース|Workspaces)$/}).first().click();
|
||||
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
|
||||
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
|
||||
await page.getByLabel('原文言語').selectOption('ja');
|
||||
await page.getByLabel('翻訳先言語').selectOption('en');
|
||||
await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).check();
|
||||
await page.getByRole('button',{name:'開始',exact:true}).click();
|
||||
await page.getByText('文字起こし中',{exact:true}).waitFor();
|
||||
await page.waitForFunction(()=>[...document.querySelectorAll('textarea')].some(x=>x.value.includes('途中結果')));
|
||||
await page.getByText('Translated: 議題は AAO の文字起こし機能です。',{exact:true}).first().waitFor();
|
||||
const before=audioFrames;
|
||||
// Navigate away via actual App navigation, then return; capture stays mounted.
|
||||
await page.screenshot({path:'/tmp/aao-transcription-live-check.png',fullPage:true});
|
||||
await page.getByRole('button',{name:/^(ヘルプ|Help)$/}).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
assert(audioFrames>before,'audio continues on another tab');
|
||||
await page.getByRole('button',{name:'録音中のチャットを開く',exact:true}).click();
|
||||
await dockA.getByText('文字起こし中',{exact:true}).waitFor();
|
||||
// Change chat through the real workspace list: A must keep recording and B must not acquire audio.
|
||||
const expand=page.getByTestId('space-chat-list-expand');
|
||||
if(await expand.isVisible()) await expand.click();
|
||||
await page.locator('[data-testid="space-chat-row"][data-task-id="2"]').getByText('会議 B',{exact:true}).click();
|
||||
const dockB=page.locator('[data-transcription-task="2"]');
|
||||
if(!await dockB.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
|
||||
await dockB.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
|
||||
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isDisabled(),'chat B cannot start while A records');
|
||||
const beforeB=audioFrames;await page.waitForTimeout(700);assert(audioFrames>beforeB,'A audio continues while B is visible');
|
||||
const firstSave=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
|
||||
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).click();
|
||||
const firstStored=await (await firstSave).json();
|
||||
assert.equal(firstStored.recording.taskId,1);
|
||||
assert(firstStored.workspaceFiles && !firstStored.workspaceError,'original transcript is also written into A workspace');
|
||||
assert.equal(finals,2); assert(audioFrames>0);
|
||||
assert(await dockB.isVisible(),'global stop keeps chat B displayed');
|
||||
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).waitFor({state:'hidden'});
|
||||
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isEnabled(),'finishing hidden A releases the recording lock');
|
||||
await page.locator('[data-testid="space-chat-row"][data-task-id="1"]').getByText('会議 A',{exact:true}).click();
|
||||
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
|
||||
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
|
||||
assert.equal(await page.locator('.transcription-transcript article').count(),2);
|
||||
await page.waitForFunction(()=>{
|
||||
const pairs=[...document.querySelectorAll('.transcription-pair')];
|
||||
return pairs.length===4 && pairs.every(p=>p.textContent.includes('翻訳済み'));
|
||||
});
|
||||
await page.getByText('未保存の変更があります。',{exact:true}).waitFor();
|
||||
const savedTranslation=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
|
||||
await page.getByRole('button',{name:'保存 / 再試行',exact:true}).click();
|
||||
const saveResult=await (await savedTranslation).json();const stored=saveResult.recording;
|
||||
assert.equal(stored.taskId,1);assert(!saveResult.workspaceError);
|
||||
const taskARecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=1')).json();
|
||||
const taskBRecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=2')).json();
|
||||
assert.equal(taskARecordings.recordings.length,1);assert.equal(taskBRecordings.recordings.length,0);
|
||||
const fileJson=JSON.parse(readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.json),'utf8'));
|
||||
assert.deepEqual(fileJson.entries,stored.entries);assert.equal(fileJson.taskId,1);
|
||||
const fileText=readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.txt),'utf8');
|
||||
assert(fileText.includes('Translated:') && fileText.includes('原文') && fileText.includes('改訂'));
|
||||
assert.equal(readdirSync(tasks[1].workspacePath).length,0,'B workspace remains untouched');
|
||||
assert(provenance.some(entry=>entry.taskId===1 && entry.relPath.endsWith('.json')));
|
||||
assert(stored.entries.every(e=>e.blocks.every(b=>b.translation?.sourceRevision===b.revision && b.translation.sourceText===b.text)));
|
||||
assert.equal(new Set(stored.entries.flatMap(e=>e.blocks.map(b=>b.id))).size,4);
|
||||
await page.screenshot({path:'/tmp/aao-transcription-wide-check.png',fullPage:true});
|
||||
await page.reload();
|
||||
await page.locator('[data-transcription-task="1"] .transcription-recording button').first().click();
|
||||
await page.locator('.transcription-transcript article').nth(1).waitFor();
|
||||
assert.equal(await page.locator('.transcription-transcript article').count(),2);
|
||||
assert.equal(await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).isChecked(),false);
|
||||
assert.equal(await page.locator('.transcription-pair').count(),4);
|
||||
assert.equal(await page.getByText('Translated: 次回までに動作を確認します。',{exact:true}).count(),2);
|
||||
const downloaded=page.waitForEvent('download');await page.getByRole('button',{name:'TXT',exact:true}).click();await downloaded;
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.screenshot({path:'/tmp/aao-transcription-narrow-check.png',fullPage:true});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'no horizontal overflow');
|
||||
assert(await page.getByTestId('space-chat-tab-transcription').isVisible(),'transcription tab remains reachable on mobile');
|
||||
console.log(JSON.stringify({audioFrames,finals,entries:2,translatedRequests:translatedRequests.length,taskId:1,workspaceFiles:saveResult.workspaceFiles,errors,screenshots:['/tmp/aao-transcription-wide-check.png','/tmp/aao-transcription-narrow-check.png']}));
|
||||
assert.equal(errors.length,0);
|
||||
}catch(error){console.error(JSON.stringify({url:page?.url(),errors,body:await page?.locator('body').innerText().catch(()=> '')}));await page?.screenshot({path:'/tmp/aao-transcription-failure.png',fullPage:true}).catch(()=>{});throw error;}finally{await browser?.close();stop();server.closeAllConnections();server.close();upstream.clients.forEach(s=>s.terminate());upstream.close();translationServer.closeAllConnections();translationServer.close();db.close();rmSync(fixtureRoot,{recursive:true,force:true});}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Local full-App browser check against built assets, with synthetic API data only.
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { chromium } from 'playwright';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const timestamp = new Date().toISOString();
|
||||
const space = { id: 'browser-space', kind: 'personal', title: 'Browser Workspace', description: '', ownerId: 'browser-test', visibility: 'private', status: 'open', myRole: 'owner', createdAt: timestamp, updatedAt: timestamp };
|
||||
const task = { id: 1, title: 'Inspector fixture', body: 'SECRET_BODY', pieceName: 'chat', profile: 'default', outputFormat: 'text', askPolicy: 'auto', priority: 'normal', state: 'succeeded', ownerId: 'browser-test', visibility: 'private', spaceId: space.id, createdAt: timestamp, updatedAt: timestamp, latestJob: null, subtasks: [], tags: [], allowedTools: [] };
|
||||
const app = express();
|
||||
app.get('/api/auth/me', (req, res) => res.json({ id: 'browser-test', name: 'Browser Test', email: '[email protected]', role: req.headers.cookie?.includes('nonadmin=1') ? 'user' : 'admin' }));
|
||||
app.get('/api/setup/status', (_req, res) => res.json({ needsSetup: false, authActive: true }));
|
||||
app.get('/api/config', (_req, res) => res.json({ config: { configVersion: 2, tools: {}, storage: {}, llm: { workers: [] } }, overriddenByEnv: {} }));
|
||||
app.get('/api/local/spaces', (_req, res) => res.json([space]));
|
||||
app.get('/api/local/spaces/:id', (_req, res) => res.json(space));
|
||||
app.get('/api/local/spaces/:id/members', (_req, res) => res.json([{ userId: 'browser-test', name: 'Browser Test', role: 'owner', isOwner: true }]));
|
||||
app.get('/api/local/tasks', (_req, res) => res.json({ tasks: [task] }));
|
||||
app.get('/api/local/tasks/:id', (_req, res) => res.json({ task }));
|
||||
app.get('/api/local/tasks/:id/overview-metrics', (_req, res) => res.json({ metrics: { collecting: false } }));
|
||||
app.get('/api/local/tasks/:id/comments', (_req, res) => res.json({ comments: [] }));
|
||||
app.get('/api/local/tasks/:id/movement-history', (_req, res) => res.json({ events: [] }));
|
||||
app.get('/api/local/tasks/:id/executions', (_req, res) => res.json({ executions: [], active: false }));
|
||||
app.get(['/api/local/tasks/:id/files', '/api/local/spaces/:id/files'], (_req, res) => res.json({ basePath: '', path: '', entries: [], truncated: false }));
|
||||
app.get('/api/local/tasks/:id/console/status', (_req, res) => res.json({ active: false }));
|
||||
app.get('/api/pieces', (_req, res) => res.json({ pieces: [] }));
|
||||
app.get('/api/pieces/:name', (req, res) => res.json({ piece: { name: req.params.name, description: '', movements: [], max_movements: 1, initial_movement: '' }, source: 'builtin' }));
|
||||
app.get('/api/transcription/config', (_req, res) => res.json({ configured: false, finalizeTimeoutSeconds: 180 }));
|
||||
app.get('/api/transcription/recordings', (_req, res) => res.json({ recordings: [] }));
|
||||
app.get('/api/users/me/pets', (_req, res) => res.json({ pets: [], settings: { enabled: false, activePetId: null, size: 48, position: 'bottom-right', workerPets: {} } }));
|
||||
app.get('/api/notifications/capabilities', (_req, res) => res.json({}));
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not included in the local browser fixture' }));
|
||||
app.use('/ui', express.static(resolve(root, 'ui/dist')));
|
||||
app.get('/ui/*', (_req, res) => res.sendFile(resolve(root, 'ui/dist/index.html')));
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let browser, page;
|
||||
const errors = [];
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('maestro.lang', 'ja');
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Synthetic clipboard denial'); } } });
|
||||
});
|
||||
page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`${base}/ui/?space=browser-space&chat=1&tab=transcription&secret=SECRET_QUERY`);
|
||||
const input = page.locator('[data-ui-id="transcription.title"]');
|
||||
await input.waitFor();
|
||||
await input.fill('SECRET_INPUT');
|
||||
await page.evaluate(() => history.replaceState(null, '', location.href + '&secret=SECRET_QUERY'));
|
||||
const activate = async () => {
|
||||
await page.getByTestId('user-account-menu-trigger').click();
|
||||
await page.getByRole('button', { name: 'UI調査モード', exact: true }).click();
|
||||
await page.getByRole('region', { name: 'UI調査', exact: true }).waitFor();
|
||||
};
|
||||
await activate();
|
||||
await input.click();
|
||||
const panel = page.getByRole('region', { name: 'UI調査', exact: true });
|
||||
await panel.locator('textarea:not([readonly])').fill('入力欄を広くしてください');
|
||||
const report = await panel.locator('textarea[readonly]').inputValue();
|
||||
assert.match(report, /ui\/src\/components\/transcription\/TranscriptionPage\.tsx:\d+:\d+/);
|
||||
assert.match(report, /transcription\.title/);
|
||||
assert.match(report, /コンポーネント: TranscriptionPage/);
|
||||
assert.match(report, /入力欄を広くしてください/);
|
||||
assert(!/SECRET_INPUT|SECRET_QUERY|SECRET_BODY|browser-test|\/home\//.test(report), 'report contains metadata and explicit wish only');
|
||||
await panel.getByRole('button', { name: '修正依頼をコピー' }).click();
|
||||
await panel.getByRole('status').filter({ hasText: '手動でコピー' }).waitFor();
|
||||
await input.focus();
|
||||
await page.keyboard.press('Control+k');
|
||||
assert.equal(await page.getByRole('dialog').count(), 0, 'command palette stays closed during inspection');
|
||||
await page.screenshot({ path: '/tmp/aao-ui-inspector-wide.png', fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await panel.locator('textarea:not([readonly])').fill('狭い画面でも入力できる');
|
||||
const bounds = await panel.boundingBox();
|
||||
assert(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 390, 'inspector fits narrow viewport');
|
||||
await page.screenshot({ path: '/tmp/aao-ui-inspector-narrow.png', fullPage: true });
|
||||
await page.keyboard.press('Escape');
|
||||
await panel.waitFor({ state: 'hidden' });
|
||||
await input.click();
|
||||
await input.press('Control+a');
|
||||
await input.pressSequentially('restored');
|
||||
assert.equal(await input.inputValue(), 'restored');
|
||||
// Radix disables outside pointer events and traps focus. Keep this regression mandatory.
|
||||
{
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.getByRole('button', { name: '+ 新規', exact: true }).first().click();
|
||||
await page.getByTestId('space-title-input').waitFor();
|
||||
// The admin shortcut is reachable even when the modal covers the account menu.
|
||||
await page.keyboard.press('Control+Alt+i');
|
||||
await page.getByTestId('space-title-input').click();
|
||||
const modalWish = page.locator('section[data-ui-inspector-control] textarea:not([readonly])');
|
||||
await modalWish.click({ timeout: 5000 });
|
||||
assert(await page.getByTestId('space-title-input').isVisible(), 'inspector panel must not dismiss the inspected Radix modal');
|
||||
await modalWish.press('Control+a');
|
||||
await modalWish.pressSequentially('modal input works');
|
||||
assert.equal(await modalWish.inputValue(), 'modal input works');
|
||||
assert(await modalWish.evaluate(element => document.activeElement === element), 'inspector retains keyboard focus outside the Radix modal');
|
||||
await page.screenshot({ path: '/tmp/aao-ui-inspector-modal.png', fullPage: true });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await context.addCookies([{ name: 'nonadmin', value: '1', url: base }]);
|
||||
await page.reload();
|
||||
await page.getByTestId('user-account-menu-trigger').click();
|
||||
assert.equal(await page.getByRole('button', { name: 'UI調査モード', exact: true }).count(), 0, 'non-admin has no inspector entry');
|
||||
await page.keyboard.press('Control+Alt+i');
|
||||
assert.equal(await page.locator('section[data-ui-inspector-control]').count(), 0, 'non-admin cannot activate the inspector shortcut');
|
||||
assert.equal(errors.length, 0, errors.join('\n'));
|
||||
console.log(JSON.stringify({ passed: true, checks: ['admin activation', 'source/component/id', 'private values excluded', 'clipboard fallback', 'keyboard shortcut blocked', '390px panel', 'Escape and normal editing', 'Radix modal preserved with real keyboard input', 'non-admin entry absent'], screenshots: ['/tmp/aao-ui-inspector-wide.png', '/tmp/aao-ui-inspector-narrow.png', '/tmp/aao-ui-inspector-modal.png'] }));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ url: page?.url(), errors, body: await page?.locator('body').innerText().catch(() => '') }));
|
||||
await page?.screenshot({ path: '/tmp/aao-ui-inspector-failure.png', fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy the Mermaid browser bundle into vendor/ so the server can render
|
||||
* diagrams (#1025).
|
||||
*
|
||||
* The copy comes from the UI's own dependency — deliberately the SAME install
|
||||
* the app renders Markdown with. Adding `mermaid` to the server's package.json
|
||||
* instead would create two versions that drift apart, which is precisely the
|
||||
* failure this tool exists to remove.
|
||||
*
|
||||
* vendor/ is gitignored and is already what the Docker image ships (noVNC lives
|
||||
* there too), so the runtime stage picks this up with the COPY it already has.
|
||||
* Runs at the end of `npm run build:ui`, when ui/node_modules is guaranteed.
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = join(root, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');
|
||||
const pkg = join(root, 'ui', 'node_modules', 'mermaid', 'package.json');
|
||||
const destDir = join(root, 'vendor', 'mermaid');
|
||||
const dest = join(destDir, 'mermaid.min.js');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.error(`[copy-mermaid-vendor] ${src} not found — run npm --prefix ui ci first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
const version = existsSync(pkg) ? JSON.parse(readFileSync(pkg, 'utf8')).version : 'unknown';
|
||||
// The UMD bundle does not expose its own version, and package.json is not
|
||||
// copied — leave a sidecar so the tool can report what it validated with.
|
||||
writeFileSync(join(destDir, 'version.txt'), `${version}\n`);
|
||||
console.log(`[copy-mermaid-vendor] mermaid ${version} -> vendor/mermaid/mermaid.min.js`);
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Known `dark:` + text-slate-<n> class names (see scripts/check-dark-text.mjs).",
|
||||
"ui/src/index.css inverts the slate ramp for the dark theme, so a bare",
|
||||
"text-slate-<n> is already right in both themes and the dark: variant flips it",
|
||||
"a second time onto the background. Some of these are still correct — on a",
|
||||
"surface that stays light in the dark theme, dark ink is what you want.",
|
||||
"This file exists so the check fails on NEW ones only. Do not add to it by",
|
||||
"hand: fix the class, then run `npm run check:dark-text -- --update`."
|
||||
],
|
||||
"total": 60,
|
||||
"files": 19,
|
||||
"classes": 6,
|
||||
"entries": [
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/CaptchaNotice.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/CaptchaNotice.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/CaptchaNotice.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ChatComposerPanel.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ChatMessage.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/RotatingTips.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/RotatingTips.tsx",
|
||||
"class": "dark:text-slate-500",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/WaterContextGauge.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/embed/EmbedBlock.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
|
||||
"class": "dark:text-slate-900",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/FileHistoryModal.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/A2aServerForm.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/A2aServerForm.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/A2aServerForm.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/ExecutionForm.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/ExecutionForm.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/ServerTlsForm.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/ServerTlsForm.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/AppRunner.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/AppRunner.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/CrossSpaceCalendar.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceCalendar.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/usage/cache/CacheWorkerCards.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/usage/UsagePage.tsx",
|
||||
"class": "dark:text-slate-100",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/usage/UsagePage.tsx",
|
||||
"class": "dark:text-slate-200",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/usage/UsagePage.tsx",
|
||||
"class": "dark:text-slate-300",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/usage/UsagePage.tsx",
|
||||
"class": "dark:text-slate-400",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// e2e-run-dir.mjs の型宣言。
|
||||
//
|
||||
// この .mjs は playwright の設定 3 本から import されており、宣言が無いと
|
||||
// UI の型検査 (`npm run typecheck:ui`) が implicit any で落ちる。実装を
|
||||
// TypeScript に移すと playwright 設定からの読み込み経路が変わるため、
|
||||
// 宣言だけを添える。
|
||||
export const OWNERSHIP_FILE: string;
|
||||
|
||||
export function createOwnedRunDir(
|
||||
cacheRoot: string,
|
||||
prefix: string,
|
||||
): { directory: string; token: string };
|
||||
|
||||
export function cleanupOwnedRunDir(args: {
|
||||
cacheRoot: string;
|
||||
directory?: string;
|
||||
prefix?: string;
|
||||
token?: string;
|
||||
}): boolean;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, relative, resolve, sep } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const OWNERSHIP_FILE = '.maestro-e2e-owned';
|
||||
|
||||
export function createOwnedRunDir(cacheRoot, prefix) {
|
||||
mkdirSync(cacheRoot, { recursive: true });
|
||||
const directory = mkdtempSync(join(realpathSync(cacheRoot), prefix));
|
||||
const token = randomUUID();
|
||||
writeFileSync(join(directory, OWNERSHIP_FILE), token, { mode: 0o600 });
|
||||
return { directory, token };
|
||||
}
|
||||
|
||||
export function cleanupOwnedRunDir({ cacheRoot, directory, prefix, token }) {
|
||||
if (!directory || !token || !prefix) return false;
|
||||
let rootReal;
|
||||
let directoryReal;
|
||||
try {
|
||||
rootReal = realpathSync(cacheRoot);
|
||||
if (lstatSync(directory).isSymbolicLink()) return false;
|
||||
directoryReal = realpathSync(directory);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
const rel = relative(rootReal, directoryReal);
|
||||
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || resolve(rootReal, rel) !== directoryReal) return false;
|
||||
if (!basename(directoryReal).startsWith(prefix)) return false;
|
||||
let actualToken;
|
||||
try { actualToken = readFileSync(join(directoryReal, OWNERSHIP_FILE), 'utf8'); } catch { return false; }
|
||||
if (actualToken !== token) return false;
|
||||
rmSync(directoryReal, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { cleanupOwnedRunDir, createOwnedRunDir } from './e2e-run-dir.mjs';
|
||||
|
||||
describe('E2E owned run directory cleanup', () => {
|
||||
const fixtureParents: string[] = [];
|
||||
const fixtureParent = (prefix: string) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix));
|
||||
fixtureParents.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of fixtureParents.splice(0)) {
|
||||
// Test-only cleanup: every path was minted and retained by fixtureParent.
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
expect(existsSync(directory)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes only an owned directory and is idempotent', () => {
|
||||
const root = fixtureParent('e2e-cache-');
|
||||
const owned = createOwnedRunDir(root, 'maestro-e2e-');
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: owned.token })).toBe(true);
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects root, external existing directories, bad tokens, and symlink traversal', () => {
|
||||
const root = fixtureParent('e2e-cache-');
|
||||
const external = fixtureParent('maestro-e2e-external-');
|
||||
writeFileSync(join(external, 'keep'), 'keep');
|
||||
const owned = createOwnedRunDir(root, 'maestro-e2e-');
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: root, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: external, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: 'wrong' })).toBe(false);
|
||||
const link = join(root, 'maestro-e2e-link');
|
||||
symlinkSync(external, link, 'dir');
|
||||
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: link, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
|
||||
mkdirSync(join(owned.directory, 'still-here'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cpSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import lockfile from 'proper-lockfile';
|
||||
import { cleanupOwnedRunDir } from './e2e-run-dir.mjs';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const runDir = process.env.E2E_RUN_TMP;
|
||||
if (!runDir) throw new Error('E2E_RUN_TMP is required');
|
||||
const lockDir = join(root, 'node_modules', '.cache', 'maestro-e2e-build.lock');
|
||||
const cacheDir = join(root, 'node_modules', '.cache');
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const run = (command, args, env = process.env) => new Promise((resolveRun, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, env, stdio: 'inherit' });
|
||||
child.once('error', reject);
|
||||
child.once('exit', code => code === 0 ? resolveRun() : reject(new Error(`${command} exited ${code}`)));
|
||||
});
|
||||
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
let server;
|
||||
try {
|
||||
const releaseBuildLock = await lockfile.lock(cacheDir, {
|
||||
lockfilePath: lockDir, stale: 120_000,
|
||||
retries: { retries: 600, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
||||
});
|
||||
try {
|
||||
await run(npm, ['run', 'build:server']);
|
||||
await run(npm, ['run', 'build:ui']);
|
||||
cpSync(join(root, 'dist'), join(runDir, 'dist'), { recursive: true });
|
||||
cpSync(join(root, 'ui', 'dist'), join(runDir, 'ui-dist'), { recursive: true });
|
||||
mkdirSync(join(runDir, 'scripts'), { recursive: true });
|
||||
cpSync(join(root, 'scripts', 'setup-lib.mjs'), join(runDir, 'scripts', 'setup-lib.mjs'));
|
||||
} finally {
|
||||
await releaseBuildLock();
|
||||
}
|
||||
|
||||
server = spawn(process.execPath, [join(runDir, 'dist', 'main.js')], {
|
||||
cwd: root,
|
||||
env: { ...process.env, UI_DIST_PATH: join(runDir, 'ui-dist') },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
const stop = () => server?.kill('SIGTERM');
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
const code = await new Promise((resolveExit, reject) => {
|
||||
server.once('error', reject);
|
||||
server.once('exit', resolveExit);
|
||||
});
|
||||
process.exitCode = typeof code === 'number' ? code : 0;
|
||||
} finally {
|
||||
cleanupOwnedRunDir({
|
||||
cacheRoot: process.env.E2E_CACHE_ROOT,
|
||||
directory: runDir,
|
||||
prefix: process.env.E2E_RUN_PREFIX,
|
||||
token: process.env.E2E_RUN_TOKEN,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
# Env override:
|
||||
# GATEWAY_PORT=4000 # gateway.listen_port を上書き (config.yaml より優先)
|
||||
# AAO_CONFIG=/etc/aao/config-gateway.yaml # 専用 config を使う場合
|
||||
# AAO_GATEWAY_NO_DB=1 # ステートレス起動 (virtual_keys は config 経由のみ、budget/rate-limit 無効)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -36,7 +35,6 @@ usage() {
|
||||
echo "Env:"
|
||||
echo " GATEWAY_PORT=$GATEWAY_PORT"
|
||||
echo " AAO_CONFIG=${AAO_CONFIG:-<repo config.yaml>}"
|
||||
echo " AAO_GATEWAY_NO_DB=${AAO_GATEWAY_NO_DB:-<unset, DB enabled>}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -73,7 +71,6 @@ do_start() {
|
||||
AAO_MODE=gateway \
|
||||
GATEWAY_PORT="$GATEWAY_PORT" \
|
||||
${AAO_CONFIG:+AAO_CONFIG="$AAO_CONFIG"} \
|
||||
${AAO_GATEWAY_NO_DB:+AAO_GATEWAY_NO_DB="$AAO_GATEWAY_NO_DB"} \
|
||||
nohup node dist/main.js >> "$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# noVNC の画面で触られたくないブラウザの機能を止める管理ポリシーを入れる (issue #1148)。
|
||||
#
|
||||
# 入れているのは 2 つ。
|
||||
#
|
||||
# AllowFileSelectionDialogs=false ファイル選択の画面を出させない(下の説明)
|
||||
# IncognitoModeAvailability=1 シークレット窓を開かせない
|
||||
#
|
||||
# シークレット窓を止める理由。**遮断が効かないからではない。**
|
||||
# 実測ではこうなる。launchPersistentContext で立ち上げると、人が Ctrl+Shift+N で
|
||||
# 開いたシークレット窓は既定の入れ物のページとして Playwright に見え、
|
||||
# 既定の入れ物に掛けた遮断がそのまま効く(127.0.0.1 への遷移が
|
||||
# ERR_BLOCKED_BY_CLIENT で断たれることを確認済み)。つまり今の Playwright では、
|
||||
# このポリシーが無くても穴は開いていない。
|
||||
#
|
||||
# それでも止めるのは保険。「シークレット窓が既定の入れ物のページとして見える」のは
|
||||
# Playwright の内部の振る舞いで、版が変われば変わりうる。変わった瞬間に
|
||||
# 遮断の掛からない窓が生まれるので、その手前で開けなくしておく。
|
||||
# エージェント側の入れ物づくり(browser.newContext)はこのポリシーの影響を
|
||||
# 受けない(実機で確認済み)。
|
||||
#
|
||||
# noVNC の画面(CAPTCHA を人が解く画面)は、ブラウザの中身がそのまま人に触れる。
|
||||
# ファイルを選ぶボタンを押すと OS のファイル選択画面が開き、サーバー上のファイルを
|
||||
# 選んで持ち出せてしまう。Chromium 系の管理ポリシー AllowFileSelectionDialogs を
|
||||
# false にすると、押しても何も起きなくなる。
|
||||
#
|
||||
# エージェント側は壊れない。**エージェントがブラウザへファイルを送る経路が
|
||||
# そもそも無い**(src/ に filechooser も setInputFiles も 1 か所も無い。
|
||||
# ui/e2e/ にあるのは UI の E2E テストが画面を操作するためのもので、
|
||||
# エージェントの経路ではない)。
|
||||
# 将来ファイル送信を足すときは setInputFiles(内部は CDP の
|
||||
# DOM.setFileInputFiles)を使うこと。あれは選択画面を通らないので
|
||||
# このポリシーの影響を受けない。
|
||||
#
|
||||
# 置き場所はブラウザの銘柄ごとに違う。Playwright 同梱のものは
|
||||
# 「Google Chrome for Testing」なので /etc/opt/chrome_for_testing/policies/managed。
|
||||
# config.yaml の browser.channel で本家 Chrome / Chromium に差し替えられるので、
|
||||
# 3 か所すべてに置く。
|
||||
# msedge は未対応。Edge の置き場所は裏が取れていないので、推測で足さない
|
||||
# (browser.channel: msedge を選ぶと、このポリシーは効かない)。
|
||||
#
|
||||
# 使い方: sudo scripts/install-browser-policy.sh
|
||||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)/deploy/browser-policy/agent-orchestrator.json"
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "ERROR: ポリシーファイルが見つかりません: $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command -v sudo > /dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
echo "ERROR: root 権限が必要です(sudo も見つかりません)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
POLICY_DIRS=(
|
||||
/etc/opt/chrome_for_testing/policies/managed
|
||||
/etc/opt/chrome/policies/managed
|
||||
/etc/chromium/policies/managed
|
||||
)
|
||||
|
||||
for dir in "${POLICY_DIRS[@]}"; do
|
||||
$SUDO mkdir -p "$dir"
|
||||
$SUDO cp "$SRC" "$dir/agent-orchestrator.json"
|
||||
$SUDO chmod 644 "$dir/agent-orchestrator.json"
|
||||
echo " ✓ $dir/agent-orchestrator.json"
|
||||
done
|
||||
|
||||
echo "ファイル選択の画面とシークレット窓を禁じるポリシーを入れました。"
|
||||
echo "既に動いているブラウザには効きません。反映するには再起動してください(scripts/server.sh restart)。"
|
||||
@@ -47,10 +47,16 @@ if $PIP install --break-system-packages -r "$REQ"; then
|
||||
elif $PIP install -r "$REQ"; then
|
||||
:
|
||||
else
|
||||
soft_fail "pip install failed. If this is a permission error, retry as: sudo $PIP install --break-system-packages -r runtime/python-requirements.txt"
|
||||
# 導入に失敗しても、ここでは抜けない。**何が足りないかを出してから**抜ける。
|
||||
# 以前はここで即座に終わっていたので、ログに残るのが「入らなかった」だけで、
|
||||
# どのパッケージが欠けたのか誰にも分からなかった。pip は 1 件失敗すると
|
||||
# そこで全部やめるため、要求ファイルの後ろ側がまとめて落ちる(実例: 版を
|
||||
# Python 3.11 以上のものに固定してしまい、3.10 の本番で 8 件が欠けた。
|
||||
# issue #1088)。
|
||||
INSTALL_FAILED=1
|
||||
fi
|
||||
|
||||
echo "[prebake-python] done. Verifying imports..."
|
||||
echo "[prebake-python] Verifying imports..."
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - <<'PYEOF' || echo "[prebake-python] WARNING: some packages failed to import (see above)." >&2
|
||||
import importlib, sys
|
||||
@@ -76,3 +82,8 @@ if missing:
|
||||
print(f"[prebake-python] all {len(mods)} packages import OK.")
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
if [ "${INSTALL_FAILED:-0}" -eq 1 ]; then
|
||||
soft_fail "pip install failed. If this is a permission error, retry as: sudo $PIP install --break-system-packages -r runtime/python-requirements.txt"
|
||||
fi
|
||||
|
||||
|
||||
+11
-7
@@ -119,19 +119,23 @@ fi
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# 5. LibreOffice(PowerPoint プレビューの画像化用・オプション)
|
||||
# 5. LibreOffice(Officeプレビュー・Excel数式検証用、オプション)
|
||||
# -------------------------------------------------
|
||||
echo "[5/5] LibreOffice(オプション)..."
|
||||
|
||||
# ファイルプレビューで .pptx/.ppt をスライド画像化する際、soffice で PDF 化してから
|
||||
# PyMuPDF で PNG にする(src/bridge/office-preview.ts)。未導入でも他機能には影響せず、
|
||||
# PowerPoint プレビューだけが「変換エンジン未導入」のダウンロード案内に切り替わる。
|
||||
# Excel(.xlsx/.xlsm) プレビューは exceljs(npm)で完結するので soffice 不要。
|
||||
# Docker イメージでは Dockerfile が libreoffice-impress を同梱する。
|
||||
# .pptx/.ppt の画像化にはsoffice、Excel詳細編集の数式再計算にはCalc + Python UNOを使う。
|
||||
# 未導入でもExcelプレビューとセル単位の軽量編集は利用できる。Dockerイメージは
|
||||
# libreoffice-impress / libreoffice-calc / python3-unoを同梱する。
|
||||
if command -v soffice >/dev/null 2>&1; then
|
||||
pass "soffice: $(command -v soffice)"
|
||||
else
|
||||
warn "soffice 未検出 — PowerPoint(.pptx/.ppt) プレビューの画像化に必要です。'sudo apt install --no-install-recommends libreoffice-impress' を実行してください(Excel プレビューには不要)"
|
||||
warn "soffice 未検出 — PowerPointプレビューとExcel数式検証に必要です。'sudo apt install --no-install-recommends libreoffice-impress libreoffice-calc python3-uno' を実行してください(Excelプレビューには不要)"
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c 'import uno' >/dev/null 2>&1; then
|
||||
pass "Python UNO: 利用可能"
|
||||
else
|
||||
warn "Python UNO 未検出 — Excel詳細編集の数式検証に必要です。'sudo apt install --no-install-recommends libreoffice-calc python3-uno' を実行してください"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Offline regression check for the server egress boundary artifacts.
|
||||
*
|
||||
* This check never applies host firewall/systemd policy and never contacts the
|
||||
* public network. It validates the shipped templates, exercises an in-process
|
||||
* exact-host+port proxy policy, and demonstrates the transports that still
|
||||
* require the OS boundary because HTTP proxy environment variables do not
|
||||
* contain them.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import dgram from 'node:dgram';
|
||||
import { Resolver } from 'node:dns/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
const SYSTEMD_FILE = new URL('../../deploy/egress/systemd/maestro-egress.service.example', import.meta.url);
|
||||
const SQUID_FILE = new URL('../../deploy/egress/squid.conf', import.meta.url);
|
||||
const ALLOWED_HOST = 'allowed.egress.invalid';
|
||||
const DENIED_HOST = 'denied.egress.invalid';
|
||||
const ALLOWED_PORT = 443;
|
||||
const SECRET = 'EGRESS_SECRET_CANARY_7d7fc02c';
|
||||
|
||||
function listen(server, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
const onReady = () => {
|
||||
server.off('error', reject);
|
||||
resolve(server.address());
|
||||
};
|
||||
if (typeof server.listen === 'function') server.listen(0, host, onReady);
|
||||
else server.bind(0, host, onReady);
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
server.closeAllConnections?.();
|
||||
server.close();
|
||||
}
|
||||
|
||||
function runNode(code, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--use-env-proxy', '-e', code],
|
||||
{
|
||||
env: { PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8', ...env },
|
||||
encoding: 'utf8',
|
||||
timeout: 8_000,
|
||||
maxBuffer: 64 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`local child probe failed: ${stderr.trim() || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function rawExchange(host, port, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
let response = '';
|
||||
const timer = setTimeout(() => socket.destroy(new Error('raw probe timed out')), 3_000);
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => socket.end(request));
|
||||
socket.on('data', (chunk) => { response += chunk; });
|
||||
socket.on('error', reject);
|
||||
socket.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseTarget(value, fallbackScheme = 'http:') {
|
||||
try {
|
||||
const url = value.includes('://') ? new URL(value) : new URL(`${fallbackScheme}//${value}`);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
||||
};
|
||||
} catch {
|
||||
return { hostname: '', port: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function allowedTarget(target) {
|
||||
return target.hostname === ALLOWED_HOST && target.port === ALLOWED_PORT;
|
||||
}
|
||||
|
||||
async function validateTemplates() {
|
||||
const [unit, squid] = await Promise.all([
|
||||
readFile(SYSTEMD_FILE, 'utf8'),
|
||||
readFile(SQUID_FILE, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(unit, /^IPAddressDeny=any$/m);
|
||||
assert.match(unit, /^IPAddressAllow=127\.0\.0\.2\/32$/m);
|
||||
assert.match(unit, /^IPAddressAllow=127\.0\.0\.3\/32$/m);
|
||||
assert.doesNotMatch(unit, /^IPAddressAllow=(?:localhost|127\.0\.0\.0\/8|::1(?:\/128)?)$/m);
|
||||
assert.match(unit, /ExecStart=\/usr\/bin\/env .*HTTP_PROXY=http:\/\/127\.0\.0\.2:3128 .*HTTPS_PROXY=http:\/\/127\.0\.0\.2:3128 .*NO_PROXY= .*\/usr\/bin\/node --use-env-proxy/m);
|
||||
assert.doesNotMatch(unit, /NODE_OPTIONS=/);
|
||||
|
||||
assert.match(squid, /^http_port 127\.0\.0\.2:3128$/m);
|
||||
assert.match(squid, /^acl maestro_llm_host dstdomain -n allowed\.egress\.invalid$/m);
|
||||
assert.match(squid, /^acl maestro_llm_port port 443$/m);
|
||||
assert.match(squid, /^http_access allow maestro_llm_host maestro_llm_port$/m);
|
||||
assert.match(squid, /^access_log none$/m);
|
||||
assert.match(squid, /^cache_store_log none$/m);
|
||||
const accessRules = squid.split(/\r?\n/).filter((line) => /^http_access\s+/.test(line.trim()));
|
||||
assert.equal(accessRules.at(-1)?.trim(), 'http_access deny all');
|
||||
return { systemd: true, squid: true };
|
||||
}
|
||||
|
||||
async function runProxyChecks() {
|
||||
const events = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
const selectedTarget = Array.isArray(req.headers['x-egress-target'])
|
||||
? req.headers['x-egress-target'][0]
|
||||
: req.headers['x-egress-target'];
|
||||
const target = parseTarget(selectedTarget || req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'http', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain' });
|
||||
res.end(allowed ? 'allowed' : 'denied');
|
||||
});
|
||||
proxy.on('connect', (req, socket) => {
|
||||
const target = parseTarget(req.url || '', 'http:');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'connect', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
socket.end(`HTTP/1.1 ${allowed ? '200 Connection Established' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
proxy.on('upgrade', (req, socket) => {
|
||||
const target = parseTarget(req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'websocket', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
socket.end(`HTTP/1.1 ${allowed ? '101 Switching Protocols' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
|
||||
const address = await listen(proxy, '127.0.0.1');
|
||||
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const fetchAllowed = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
||||
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${ALLOWED_HOST}:${ALLOWED_PORT}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
const fetchDenied = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
||||
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${DENIED_HOST}:${ALLOWED_PORT}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
assert.equal(fetchAllowed.status, 200);
|
||||
assert.equal(fetchDenied.status, 403);
|
||||
await Promise.all([fetchAllowed.text(), fetchDenied.text()]);
|
||||
|
||||
const connectAllowed = await rawExchange('127.0.0.1', address.port,
|
||||
`CONNECT ${ALLOWED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nAuthorization: Bearer ${SECRET}\r\n\r\n`);
|
||||
const connectDenied = await rawExchange('127.0.0.1', address.port,
|
||||
`CONNECT ${DENIED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\n\r\n`);
|
||||
assert.match(connectAllowed, /^HTTP\/1\.1 200/);
|
||||
assert.match(connectDenied, /^HTTP\/1\.1 403/);
|
||||
|
||||
const wsAllowed = await rawExchange('127.0.0.1', address.port,
|
||||
`GET ws://${ALLOWED_HOST}:${ALLOWED_PORT}/socket?token=${SECRET} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
||||
const wsDenied = await rawExchange('127.0.0.1', address.port,
|
||||
`GET ws://${DENIED_HOST}:${ALLOWED_PORT}/socket HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
||||
assert.match(wsAllowed, /^HTTP\/1\.1 101/);
|
||||
assert.match(wsDenied, /^HTTP\/1\.1 403/);
|
||||
|
||||
const serializedEvents = JSON.stringify(events);
|
||||
assert.ok(!serializedEvents.includes(SECRET), 'metadata events must not retain request secrets');
|
||||
assert.equal(events.filter((event) => event.decision === 'allow').length, 3);
|
||||
assert.equal(events.filter((event) => event.decision === 'deny').length, 3);
|
||||
return { allowed: 3, denied: 3, secretRetained: false };
|
||||
} finally {
|
||||
close(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
async function reserveTcpPort() {
|
||||
const server = net.createServer();
|
||||
const address = await listen(server, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function runActualEnvProxyChild(port) {
|
||||
const events = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
const target = parseTarget(req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({
|
||||
transport: 'absolute-form',
|
||||
decision: allowed ? 'allow' : 'deny',
|
||||
target: `${target.hostname}:${target.port}`,
|
||||
absoluteUrl: /^http:\/\//.test(req.url || ''),
|
||||
});
|
||||
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain', connection: 'close' });
|
||||
res.end(allowed ? 'allowed' : 'denied');
|
||||
});
|
||||
proxy.on('connect', (req, socket) => {
|
||||
const target = parseTarget(req.url || '', 'http:');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({
|
||||
transport: 'connect',
|
||||
decision: allowed ? 'allow' : 'deny',
|
||||
target: `${target.hostname}:${target.port}`,
|
||||
absoluteUrl: false,
|
||||
});
|
||||
if (!allowed) {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
socket.once('data', () => {
|
||||
socket.end('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 7\r\nConnection: close\r\n\r\nallowed');
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
proxy.once('error', reject);
|
||||
proxy.listen(port, '127.0.0.1', () => {
|
||||
proxy.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const init = {
|
||||
headers: { Authorization: `Bearer ${SECRET}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
};
|
||||
const allowedResponse = await fetch(`http://${ALLOWED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
||||
const allowedStatus = allowedResponse.status;
|
||||
await allowedResponse.text();
|
||||
let deniedStatus = 'error';
|
||||
try {
|
||||
const deniedResponse = await fetch(`http://${DENIED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
||||
deniedStatus = deniedResponse.status;
|
||||
await deniedResponse.text();
|
||||
} catch {}
|
||||
return { allowedStatus, deniedStatus, events };
|
||||
} finally {
|
||||
close(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
async function runActualEnvProxyCheck() {
|
||||
const port = await reserveTcpPort();
|
||||
const proxyUrl = `http://127.0.0.1:${port}`;
|
||||
const childEnv = {
|
||||
PATH: process.env.PATH || '/usr/bin:/bin',
|
||||
HOME: '/tmp',
|
||||
LANG: 'C.UTF-8',
|
||||
MAESTRO_EGRESS_PROXY_CHILD: '1',
|
||||
MAESTRO_EGRESS_PROXY_PORT: String(port),
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
NO_PROXY: '',
|
||||
};
|
||||
const output = await new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--use-env-proxy', fileURLToPath(import.meta.url)],
|
||||
{ env: childEnv, encoding: 'utf8', timeout: 8_000, maxBuffer: 64 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`env proxy child failed: ${stderr.trim() || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
assert.ok(!output.includes(SECRET));
|
||||
const result = JSON.parse(output);
|
||||
assert.equal(result.allowedStatus, 200);
|
||||
assert.ok(result.deniedStatus === 403 || result.deniedStatus === 'error');
|
||||
const allowed = result.events.find((event) => event.decision === 'allow');
|
||||
const denied = result.events.find((event) => event.decision === 'deny');
|
||||
assert.equal(allowed?.target, `${ALLOWED_HOST}:${ALLOWED_PORT}`);
|
||||
assert.equal(denied?.target, `${DENIED_HOST}:${ALLOWED_PORT}`);
|
||||
if (allowed.transport === 'absolute-form') assert.equal(allowed.absoluteUrl, true);
|
||||
return {
|
||||
nodeFlag: '--use-env-proxy',
|
||||
allowedTransport: allowed.transport,
|
||||
allowedTarget: allowed.target,
|
||||
deniedTarget: denied.target,
|
||||
cleanChildEnvironment: true,
|
||||
secretRetained: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDirectBypassChecks() {
|
||||
const hits = { http: 0, raw: 0, childRaw: 0, ipv6: 0, dns: 0 };
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
hits.http += 1;
|
||||
res.end('direct-http');
|
||||
});
|
||||
const rawServer = net.createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const kind = data.toString() === 'child' ? 'childRaw' : 'raw';
|
||||
hits[kind] += 1;
|
||||
socket.end('ok');
|
||||
});
|
||||
});
|
||||
const ipv6Server = net.createServer((socket) => {
|
||||
hits.ipv6 += 1;
|
||||
socket.end('ok');
|
||||
});
|
||||
const dnsServer = dgram.createSocket('udp4');
|
||||
dnsServer.on('message', (message, remote) => {
|
||||
hits.dns += 1;
|
||||
const response = Buffer.from(message);
|
||||
response[2] = 0x81;
|
||||
response[3] = 0x83;
|
||||
response.writeUInt16BE(0, 6);
|
||||
response.writeUInt16BE(0, 8);
|
||||
response.writeUInt16BE(0, 10);
|
||||
dnsServer.send(response, remote.port, remote.address);
|
||||
});
|
||||
|
||||
const servers = [httpServer, rawServer, ipv6Server, dnsServer];
|
||||
try {
|
||||
const httpAddress = await listen(httpServer, '127.0.0.1');
|
||||
const rawAddress = await listen(rawServer, '127.0.0.1');
|
||||
const ipv6Address = await listen(ipv6Server, '::1');
|
||||
const dnsAddress = await listen(dnsServer, '127.0.0.1');
|
||||
|
||||
const directHttp = await new Promise((resolve, reject) => {
|
||||
const request = http.get(`http://127.0.0.1:${httpAddress.port}/direct`, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve(response.statusCode));
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
assert.equal(directHttp, 200);
|
||||
assert.equal(await rawExchange('127.0.0.1', rawAddress.port, 'parent'), 'ok');
|
||||
assert.equal(await rawExchange('::1', ipv6Address.port, 'ipv6'), 'ok');
|
||||
|
||||
const childResult = await runNode(`
|
||||
const net = require('node:net');
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port: ${rawAddress.port} });
|
||||
socket.on('connect', () => socket.end('child'));
|
||||
socket.on('close', () => process.exit(0));
|
||||
socket.on('error', (error) => { console.error(error); process.exit(1); });
|
||||
`, {
|
||||
HTTP_PROXY: 'http://127.0.0.1:9',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:9',
|
||||
NO_PROXY: '',
|
||||
});
|
||||
assert.equal(childResult, '');
|
||||
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers([`127.0.0.1:${dnsAddress.port}`]);
|
||||
await resolver.resolve4(`dns-${SECRET.toLowerCase()}.invalid`).catch(() => {});
|
||||
|
||||
assert.deepEqual(hits, { http: 1, raw: 1, childRaw: 1, ipv6: 1, dns: 1 });
|
||||
return {
|
||||
demonstrated: ['node:http', 'raw-tcp-ipv4', 'spawned-child-raw-tcp', 'raw-tcp-ipv6', 'direct-dns'],
|
||||
meaning: 'these transports bypass HTTP_PROXY and must be stopped by the OS boundary',
|
||||
};
|
||||
} finally {
|
||||
for (const server of servers) {
|
||||
try { close(server); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.MAESTRO_EGRESS_PROXY_CHILD === '1') {
|
||||
const port = Number(process.env.MAESTRO_EGRESS_PROXY_PORT);
|
||||
assert.ok(Number.isInteger(port) && port > 0);
|
||||
const childResult = await runActualEnvProxyChild(port);
|
||||
const childOutput = JSON.stringify(childResult);
|
||||
assert.ok(!childOutput.includes(SECRET));
|
||||
console.log(childOutput);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const templates = await validateTemplates();
|
||||
const envProxyRuntime = await runActualEnvProxyCheck();
|
||||
const proxyPolicy = await runProxyChecks();
|
||||
const proxyScope = await runDirectBypassChecks();
|
||||
const result = {
|
||||
ok: true,
|
||||
externalNetworkUsed: false,
|
||||
hostIsolationApplied: false,
|
||||
templates,
|
||||
envProxyRuntime,
|
||||
proxyPolicy,
|
||||
proxyScope,
|
||||
};
|
||||
const output = JSON.stringify(result, null, 2);
|
||||
assert.ok(!output.includes(SECRET));
|
||||
console.log(output);
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
// Real worker startup + browser CSP regression. All canaries are loopback-only.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, writeFile, rm, mkdir, copyFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import express from 'express';
|
||||
import { registerBrowserEgress } from '../../dist/bridge/egress-csp.js';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'maestro-egress-'));
|
||||
const secret = 'egress-canary-secret-must-not-be-logged';
|
||||
const servers = [];
|
||||
let browser;
|
||||
let worker;
|
||||
async function listen(server) {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
|
||||
async function stopWorker() {
|
||||
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
|
||||
const exiting = once(worker, 'exit'); worker.kill('SIGTERM');
|
||||
const timer = setTimeout(() => worker?.kill('SIGKILL'), 5000);
|
||||
try { await exiting; } finally { clearTimeout(timer); worker = undefined; }
|
||||
}
|
||||
try {
|
||||
await mkdir(join(temporary, 'dist/db'), { recursive: true });
|
||||
await copyFile(join(root, 'dist/db/schema.sql'), join(temporary, 'dist/db/schema.sql'));
|
||||
const invalidPolicy = join(temporary, 'invalid-policy.json');
|
||||
await writeFile(invalidPolicy, JSON.stringify({ secret }));
|
||||
for (const entry of ['main.js', 'index.js']) {
|
||||
const invalidStart = spawnSync(process.execPath, [join(root, 'dist', entry)], {
|
||||
cwd: temporary, env: { PATH: process.env.PATH, HOME: temporary, AAO_EGRESS_POLICY: invalidPolicy },
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
assert.equal(invalidStart.status, 1, 'invalid policy rejects startup');
|
||||
assert(invalidStart.stderr.includes('Invalid egress policy'));
|
||||
assert(!invalidStart.stderr.includes(secret));
|
||||
assert(!invalidStart.stdout.includes('maestro starting'), 'no worker startup with invalid policy');
|
||||
}
|
||||
let allowedHits = 0, deniedHits = 0;
|
||||
const makeCanary = hit => {
|
||||
const server = createServer((req, res) => {
|
||||
hit(); res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.url.startsWith('/image')) {
|
||||
res.setHeader('Content-Type', 'image/svg+xml'); res.end('<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2"/></svg>');
|
||||
} else { res.end('ok'); }
|
||||
});
|
||||
const ws = new WebSocketServer({ server }); ws.on('connection', socket => { hit(); socket.close(); });
|
||||
return server;
|
||||
};
|
||||
const allowed = await listen(makeCanary(() => allowedHits++));
|
||||
const denied = await listen(makeCanary(() => deniedHits++));
|
||||
let llmHits = 0;
|
||||
const llm = await listen(createServer((_req, res) => { llmHits++; res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"egress-test-model"}],"models":[]}'); }));
|
||||
const policyPath = join(temporary, 'policy.json');
|
||||
const configPath = join(temporary, 'config.yaml');
|
||||
await writeFile(configPath, 'safety:\n bash_allow_network: true\n');
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
for (const mode of ['report-only', 'enforce']) {
|
||||
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
|
||||
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const policy = { version: 1, observeNode: true, browser: {
|
||||
mode, connectOrigins: [allowed, allowed.replace('http:', 'ws:')], imageOrigins: [allowed], frameOrigins: [],
|
||||
} };
|
||||
await writeFile(policyPath, JSON.stringify(policy));
|
||||
const fixtureApp = express();
|
||||
registerBrowserEgress(fixtureApp, policy);
|
||||
fixtureApp.get('/sandbox', (_req, res) => {
|
||||
res.setHeader('Content-Security-Policy', 'sandbox');
|
||||
res.type('html').send(`<img id=canary src="${denied}/image?token=${secret}">`);
|
||||
});
|
||||
fixtureApp.get('/', (_req, res) => res.type('html').send('<iframe src="/sandbox"></iframe>'));
|
||||
const fixture = await listen(createServer(fixtureApp));
|
||||
const initialLlmHits = llmHits;
|
||||
let output = '';
|
||||
worker = spawn(process.execPath, [join(root, 'dist/main.js')], {
|
||||
cwd: temporary,
|
||||
// Do not inherit real integrations, credentials, proxies, preload scripts or developer config.
|
||||
env: { PATH: process.env.PATH, HOME: temporary, NODE_ENV: 'test', AAO_MODE: 'worker',
|
||||
AAO_CONFIG: configPath, AAO_EGRESS_POLICY: policyPath, PORT: String(port), LOG_LEVEL: 'info',
|
||||
DB_PATH: join(temporary, `${mode}.db`), WORKTREE_DIR: join(temporary, `${mode}-workspaces`),
|
||||
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'egress-test-model' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
worker.on('error', error => { output += error.message; });
|
||||
const capture = chunk => { output += chunk.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
|
||||
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
if (worker.exitCode !== null) throw new Error(`Worker failed to start: ${output.slice(-6000)}`);
|
||||
try { const response = await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) }); if (response.ok) { ready = true; break; } } catch {}
|
||||
await delay(100);
|
||||
}
|
||||
assert(ready, `worker ready (${mode})`);
|
||||
const context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
context.setDefaultTimeout(15000);
|
||||
const unexpected = [];
|
||||
const pageErrors = [];
|
||||
await context.route('**/*', route => {
|
||||
const url = route.request().url();
|
||||
if (!/^https?:/.test(url) || [base, allowed, denied, fixture].includes(new URL(url).origin)) return route.continue();
|
||||
unexpected.push(new URL(url).origin); return route.abort();
|
||||
});
|
||||
await context.routeWebSocket('**/*', route => {
|
||||
const origin = new URL(route.url()).origin.replace(/^ws/, 'http');
|
||||
if ([base, allowed, denied, fixture].includes(origin)) route.connectToServer();
|
||||
else { unexpected.push(origin); route.close(); }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
// App errors only: Playwright's service-worker disabling init script itself
|
||||
// throws inside an intentionally opaque sandbox frame. The fixture has no scripts.
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
const response = await page.goto(`${base}/ui/`);
|
||||
const header = mode === 'enforce' ? 'content-security-policy' : 'content-security-policy-report-only';
|
||||
assert(response.headers()[header]?.includes('connect-src'), `${mode} CSP on real UI`);
|
||||
await page.getByTestId('nav-spaces').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('nav-spaces').click();
|
||||
await page.getByTestId('space-rail').waitFor({ state: 'visible' });
|
||||
const attempt = async (origin, kind) => page.evaluate(async ({ origin, kind, secret }) => {
|
||||
const url = `${origin}/${kind}?token=${secret}`;
|
||||
return await new Promise(resolveResult => {
|
||||
const timeout = setTimeout(() => resolveResult('timeout'), 4000);
|
||||
const finish = value => { clearTimeout(timeout); resolveResult(value); };
|
||||
if (kind === 'fetch') fetch(url).then(r => finish(r.ok ? 'ok' : 'error'), () => finish('error'));
|
||||
else if (kind === 'image') { const img = new Image(); img.onload = () => finish('ok'); img.onerror = () => finish('error'); img.src = url; }
|
||||
else { const socket = new WebSocket(url.replace('http:', 'ws:')); socket.onopen = () => { socket.close(); finish('ok'); }; socket.onerror = () => finish('error'); }
|
||||
});
|
||||
}, { origin, kind, secret });
|
||||
const startAllowed = allowedHits, startDenied = deniedHits;
|
||||
for (const kind of ['fetch', 'image', 'websocket']) {
|
||||
assert.equal(await attempt(allowed, kind), 'ok', `${mode} allowed ${kind}`);
|
||||
assert.equal(await attempt(denied, kind), mode === 'enforce' ? 'error' : 'ok', `${mode} unlisted ${kind}`);
|
||||
}
|
||||
assert.equal(allowedHits - startAllowed, 3);
|
||||
assert.equal(deniedHits - startDenied, mode === 'enforce' ? 0 : 3);
|
||||
const sandboxPage = await context.newPage();
|
||||
const sandboxResponse = await context.request.get(`${fixture}/sandbox`);
|
||||
assert(sandboxResponse.headers()['content-security-policy'].includes('sandbox'), 'sandbox is preserved');
|
||||
assert(sandboxResponse.headers()[header].includes('img-src'), 'egress CSP survives later sandbox header');
|
||||
const beforeFrame = deniedHits;
|
||||
await sandboxPage.goto(fixture);
|
||||
const canary = sandboxPage.frameLocator('iframe').locator('#canary');
|
||||
await canary.waitFor({ state: 'attached' });
|
||||
for (let i = 0; i < 50 && !await canary.evaluate(img => img.complete); i++) await delay(100);
|
||||
assert(await canary.evaluate(img => img.complete), 'iframe image request completes');
|
||||
assert.equal(await canary.evaluate(img => img.naturalWidth > 0), mode === 'report-only', 'same-origin sandbox iframe egress');
|
||||
assert.equal(deniedHits - beforeFrame, mode === 'enforce' ? 0 : 1);
|
||||
await sandboxPage.close();
|
||||
for (let i = 0; i < 30 && !output.includes(`[security:csp] blocked="${denied}"`); i++) await delay(100);
|
||||
assert(output.includes(`[security:csp] blocked="${denied}"`), 'browser reports reach real worker');
|
||||
assert(!output.includes(secret), 'canary secret absent from all worker logs');
|
||||
assert.deepEqual(unexpected, [], 'normal UI must not contact unknown origins');
|
||||
// Stay idle across the observation interval and backend status refresh cycle.
|
||||
if (mode === 'enforce') {
|
||||
for (let i = 0; i < 13; i++) { await delay(5000); assert.equal(worker.exitCode, null); }
|
||||
assert(output.includes('[egress-summary]'), 'idle observation summary');
|
||||
}
|
||||
assert(output.includes('[egress] Node destination observation enabled'));
|
||||
const observationLines = output.split('\n').filter(line => /\[egress(?:-summary)?\]/.test(line));
|
||||
assert(!observationLines.some(line => line.includes(secret)), 'secrets absent from observations');
|
||||
let sawLlmHttp = false, sawLlmTcp = false;
|
||||
for (const line of observationLines) {
|
||||
const json = line.slice(line.indexOf('] {') + 2);
|
||||
if (!json.startsWith('{')) continue;
|
||||
const value = JSON.parse(json);
|
||||
const entries = value.entries ?? [value];
|
||||
for (const entry of entries) {
|
||||
sawLlmHttp ||= entry.kind === 'http' && entry.destination === llm;
|
||||
sawLlmTcp ||= entry.kind === 'tcp' && entry.destination === llm.replace('http:', 'tcp:');
|
||||
assert([llm, allowed, denied, base].includes(entry.destination)
|
||||
|| [llm, allowed, denied, base].map(origin => origin.replace('http:', 'tcp:')).includes(entry.destination), 'only controlled destinations observed');
|
||||
}
|
||||
}
|
||||
assert(llmHits > initialLlmHits, 'worker actually contacts fake LLM');
|
||||
assert(sawLlmHttp && sawLlmTcp, 'real worker HTTP and TCP observations are nonempty');
|
||||
assert.deepEqual(pageErrors, [], 'normal UI has no uncaught errors');
|
||||
assert.deepEqual(unexpected, [], 'idle UI has no unexpected destinations');
|
||||
await context.close(); await stopWorker();
|
||||
console.log(`${mode}: real UI startup, HTTP/image/WebSocket canaries, destination-only observation passed`);
|
||||
}
|
||||
} finally {
|
||||
await stopWorker(); await browser?.close();
|
||||
for (const server of servers) { server.closeAllConnections(); server.close(); }
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
// Real Settings UI + persisted config + live CSP. Only loopback test services.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, mkdir, copyFile, writeFile, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const temp = await mkdtemp(join(tmpdir(), 'maestro-egress-settings-'));
|
||||
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
|
||||
let worker, browser;
|
||||
let output = '';
|
||||
const servers = [];
|
||||
async function listen(server) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server); return `http://127.0.0.1:${server.address().port}`; }
|
||||
async function stop() {
|
||||
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
|
||||
const done = once(worker, 'exit'); worker.kill('SIGTERM');
|
||||
const timeout = setTimeout(() => worker?.kill('SIGKILL'), 5000);
|
||||
try { await done; } finally { clearTimeout(timeout); worker = undefined; }
|
||||
}
|
||||
try {
|
||||
await mkdir(join(temp, 'dist/db'), { recursive: true });
|
||||
await copyFile(join(root, 'dist/db/schema.sql'), join(temp, 'dist/db/schema.sql'));
|
||||
let hits = 0;
|
||||
const canary = await listen(createServer((_req, res) => { hits++; res.setHeader('Access-Control-Allow-Origin', '*'); res.end('ok'); }));
|
||||
const llm = await listen(createServer((_req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"test-model"}],"models":[]}'); }));
|
||||
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
|
||||
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const config = join(temp, 'config.yaml');
|
||||
await writeFile(config, 'safety:\n bash_allow_network: true\n');
|
||||
const env = { PATH: process.env.PATH, HOME: temp, NODE_ENV: 'test', AAO_MODE: 'worker', AAO_CONFIG: config,
|
||||
PORT: String(port), DB_PATH: join(temp, 'test.db'), WORKTREE_DIR: join(temp, 'workspaces'),
|
||||
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'test-model', LOG_LEVEL: 'info' };
|
||||
async function start(extra = {}) {
|
||||
output = '';
|
||||
worker = spawn(process.execPath, [join(root, 'dist/main.js')], { cwd: temp, env: { ...env, ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const capture = value => { output += value.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
|
||||
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
|
||||
worker.on('error', error => { output += error.message; });
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (worker.exitCode !== null) throw new Error(`worker startup failed: ${output.slice(-5000)}`);
|
||||
try { if ((await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error('worker did not start');
|
||||
}
|
||||
await start();
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
const context = await browser.newContext({ locale: 'ja-JP', serviceWorkers: 'block' });
|
||||
context.setDefaultTimeout(15000);
|
||||
const external = [];
|
||||
await context.route('**/*', route => {
|
||||
const origin = new URL(route.request().url()).origin;
|
||||
if ([base, canary].includes(origin)) return route.continue();
|
||||
external.push(origin); return route.abort();
|
||||
});
|
||||
await context.routeWebSocket('**/*', route => {
|
||||
if (new URL(route.url()).origin.replace(/^ws/, 'http') === base) route.connectToServer();
|
||||
else { external.push(new URL(route.url()).origin); route.close(); }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/ui/?page=settings§ion=egress-policy`);
|
||||
const mode = page.getByRole('combobox', { name: 'ブラウザの通信', exact: true });
|
||||
const observe = page.getByRole('checkbox', { name: 'サーバー通信の接続先を記録', exact: true });
|
||||
const connectOrigins = page.getByRole('textbox', { name: '接続の許可先', exact: true });
|
||||
await mode.waitFor();
|
||||
assert.equal(await mode.inputValue(), 'off');
|
||||
const pid = worker.pid;
|
||||
async function save() {
|
||||
const response = page.waitForResponse(r => r.url() === `${base}/api/config` && r.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: '保存して反映', exact: true }).click();
|
||||
assert.equal((await response).status(), 200, 'settings save succeeds');
|
||||
assert.equal(worker.pid, pid, 'settings apply without restarting worker');
|
||||
}
|
||||
async function attempt() { return page.evaluate(url => fetch(url).then(r => r.ok, () => false), canary); }
|
||||
await mode.selectOption('report-only'); await observe.check(); await save();
|
||||
assert((await fetch(`${base}/ui/`)).headers.get('content-security-policy-report-only')?.includes('connect-src'));
|
||||
await page.reload(); assert.equal(await attempt(), true, 'report-only does not block');
|
||||
assert(output.includes('Node destination observation enabled'), 'Node monitoring enabled live');
|
||||
await mode.selectOption('enforce'); await save();
|
||||
await page.reload(); const beforeBlocked = hits;
|
||||
assert.equal(await attempt(), false, 'enforce blocks unlisted destination');
|
||||
assert.equal(hits, beforeBlocked, 'blocked request never reaches destination');
|
||||
await connectOrigins.fill(canary); await save();
|
||||
await page.reload(); assert.equal(await attempt(), true, 'adding an allowed origin works');
|
||||
const onDisk = await readFile(config, 'utf8');
|
||||
assert(onDisk.includes('observe_node: true') && onDisk.includes('mode: enforce'), 'settings persisted to YAML');
|
||||
await stop(); await start(); await page.reload();
|
||||
assert.equal(await mode.inputValue(), 'enforce');
|
||||
assert.equal(await connectOrigins.inputValue(), canary);
|
||||
assert.equal(await observe.isChecked(), true);
|
||||
assert.equal(await attempt(), true, 'saved policy survives restart');
|
||||
const locked = join(temp, 'external.json');
|
||||
await writeFile(locked, JSON.stringify({ version: 1, observeNode: false, browser: { mode: 'report-only', connectOrigins: [], imageOrigins: [], frameOrigins: [] } }));
|
||||
await stop(); await start({ AAO_EGRESS_POLICY: locked }); await page.reload();
|
||||
assert.equal(await mode.inputValue(), 'report-only');
|
||||
assert.equal(await mode.isDisabled(), true); assert.equal(await observe.isDisabled(), true); assert.equal(await connectOrigins.isDisabled(), true);
|
||||
const beforeWrite = await readFile(config, 'utf8');
|
||||
const rejected = await fetch(`${base}/api/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ egress: { browser: { mode: 'off' } } }) });
|
||||
assert.equal(rejected.status, 400, 'generic API cannot bypass external lock');
|
||||
assert.equal(await readFile(config, 'utf8'), beforeWrite);
|
||||
assert.deepEqual(external, []);
|
||||
await context.close();
|
||||
console.log('Settings UI: live report/enforce/allowlist, Node monitoring, persistence, external lock passed');
|
||||
} finally {
|
||||
await stop(); await browser?.close();
|
||||
for (const server of servers) { server.closeAllConnections(); server.close(); }
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Browser regression check for UI assets that must remain local.
|
||||
*
|
||||
* This deliberately builds a throwaway Vite app from the production
|
||||
* components. It does not inspect source strings: Chromium must load the
|
||||
* bundled fonts, Monaco worker, and Leaflet marker images, edit and save a
|
||||
* file, and render a map. OSM tiles are intercepted and fulfilled locally;
|
||||
* every other cross-origin request is treated as a failure.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
const repoRoot = resolve(scriptDir, '../..');
|
||||
const uiRoot = join(repoRoot, 'ui');
|
||||
const cspMode = process.argv.includes('--csp');
|
||||
const uiNodeModules = join(uiRoot, 'node_modules');
|
||||
|
||||
const importFrom = async (path) => import(pathToFileURL(path).href);
|
||||
const vite = await importFrom(join(uiNodeModules, 'vite/dist/node/index.js'));
|
||||
const reactPluginModule = await importFrom(join(uiNodeModules, '@vitejs/plugin-react/dist/index.js'));
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightModule = await importFrom(require.resolve('playwright'));
|
||||
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
|
||||
const reactPlugin = reactPluginModule.default;
|
||||
|
||||
const localModule = (name) => join(uiNodeModules, name);
|
||||
const MONACO = join(uiRoot, 'src/components/userfolder/MonacoFileEditor.tsx');
|
||||
const MAP = join(uiRoot, 'src/components/embed/MapPlacesDetail.tsx');
|
||||
const CSS = join(uiRoot, 'src/index.css');
|
||||
const POSTCSS = join(uiRoot, 'postcss.config.js');
|
||||
|
||||
class ExternalRequestDetected extends Error {
|
||||
constructor(urls) {
|
||||
super(`external requests blocked: ${urls.join(', ')}`);
|
||||
this.name = 'ExternalRequestDetected';
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_CDN_URL = 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap';
|
||||
|
||||
function assertNoExternal(urls) {
|
||||
if (urls.length > 0) throw new ExternalRequestDetected(urls);
|
||||
}
|
||||
|
||||
const TILE_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function fixtureSource() {
|
||||
return `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>#root{min-height:900px}</style>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function fixtureAppSource() {
|
||||
return `import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18next from 'i18next';
|
||||
import { MonacoFileEditor } from ${JSON.stringify(MONACO)};
|
||||
import { MapPlacesDetail } from ${JSON.stringify(MAP)};
|
||||
import ${JSON.stringify(CSS)};
|
||||
import jaUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/ja/userfolder.json"))};
|
||||
import enUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/en/userfolder.json"))};
|
||||
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.init({
|
||||
lng: 'ja',
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
ja: { userfolder: jaUserfolder, embed: { searchResults: { map: '地図検索結果: 「{{query}}」' }, viewOn: { osm: 'OpenStreetMap で開く' } } },
|
||||
en: { userfolder: enUserfolder, embed: { searchResults: { map: 'Map search results: “{{query}}”' }, viewOn: { osm: 'Open in OpenStreetMap' } } },
|
||||
},
|
||||
});
|
||||
|
||||
const MAP_DATA = {
|
||||
query: '東京駅',
|
||||
places: [
|
||||
{ name: '東京駅', address: '東京都千代田区', lat: 35.681236, lon: 139.767125, type: 'station', details: '中央駅', mapUrl: 'https://www.openstreetmap.org/?mlat=35.681236&mlon=139.767125#map=16/35.681236/139.767125' },
|
||||
{ name: '皇居', address: '東京都千代田区', lat: 35.685175, lon: 139.752799, type: 'park', details: '公園', mapUrl: 'https://www.openstreetmap.org/' },
|
||||
],
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [saved, setSaved] = useState('');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
return <I18nextProvider i18n={i18n}>
|
||||
<main style={{ minHeight: '900px', padding: 16 }}>
|
||||
<h1>Local UI asset probe</h1>
|
||||
<div data-font="ibm-sans" style={{ fontFamily: '"IBM Plex Sans JP"' }}>日本語フォント</div>
|
||||
<div data-font="ibm-mono" style={{ fontFamily: '"IBM Plex Mono"' }}>IBM mono 0123</div>
|
||||
<div data-font="jetbrains" style={{ fontFamily: '"JetBrains Mono"' }}>JetBrains mono 0123</div>
|
||||
<section style={{ height: 300, border: '1px solid #ddd' }}>
|
||||
<MonacoFileEditor
|
||||
subdir="agents-md"
|
||||
filename="AGENTS.md"
|
||||
content="# original\\n"
|
||||
mtime="2026-01-01T00:00:00.000Z"
|
||||
size={12}
|
||||
onSave={async (value) => { setSaved(value); await new Promise(resolve => setTimeout(resolve, 150)); }}
|
||||
onDirtyChange={setDirty}
|
||||
/>
|
||||
</section>
|
||||
<output data-testid="dirty-state">{String(dirty)}</output>
|
||||
<output data-testid="save-result">{saved}</output>
|
||||
<MapPlacesDetail data={MAP_DATA} />
|
||||
</main>
|
||||
</I18nextProvider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
`;
|
||||
}
|
||||
|
||||
async function makeFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'maestro-local-ui-assets-'));
|
||||
await mkdir(join(root, 'src'), { recursive: true });
|
||||
await writeFile(join(root, 'index.html'), fixtureSource());
|
||||
await writeFile(join(root, 'src/main.tsx'), fixtureAppSource());
|
||||
return root;
|
||||
}
|
||||
|
||||
async function buildFixture(root, outDir) {
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(uiRoot);
|
||||
try {
|
||||
await vite.build({
|
||||
configFile: false,
|
||||
root,
|
||||
base: '/ui/',
|
||||
plugins: [reactPlugin()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
alias: [
|
||||
{ find: /^react$/, replacement: localModule('react') },
|
||||
{ find: /^react\/(.*)$/, replacement: `${localModule('react')}/$1` },
|
||||
{ find: /^react-dom$/, replacement: localModule('react-dom') },
|
||||
{ find: /^react-dom\/(.*)$/, replacement: `${localModule('react-dom')}/$1` },
|
||||
{ find: /^react-i18next$/, replacement: localModule('react-i18next') },
|
||||
{ find: /^i18next$/, replacement: localModule('i18next') },
|
||||
],
|
||||
},
|
||||
css: { postcss: POSTCSS },
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { input: join(root, 'index.html') },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function closePreview(previewInfo) {
|
||||
const preview = previewInfo?.preview ?? previewInfo;
|
||||
if (!preview) return;
|
||||
if (typeof preview.close === 'function') {
|
||||
await preview.close();
|
||||
return;
|
||||
}
|
||||
if (preview.httpServer) {
|
||||
await new Promise((resolveClose) => preview.httpServer.close(resolveClose));
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview(root) {
|
||||
const headers = {};
|
||||
if (cspMode) {
|
||||
const { buildBrowserCsp } = await importFrom(join(repoRoot, 'dist/bridge/egress-csp.js'));
|
||||
headers['Content-Security-Policy'] = buildBrowserCsp({
|
||||
mode: 'enforce', connectOrigins: [], imageOrigins: ['https://tile.openstreetmap.org'], frameOrigins: [],
|
||||
});
|
||||
}
|
||||
const preview = await vite.preview({
|
||||
configFile: false,
|
||||
root,
|
||||
base: '/ui/',
|
||||
preview: { host: '127.0.0.1', port: 0, strictPort: false, headers },
|
||||
});
|
||||
const address = preview.httpServer.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Vite preview did not expose a TCP address');
|
||||
return { preview, origin: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function browserCheck() {
|
||||
const root = await makeFixture();
|
||||
const outDir = join(root, 'dist');
|
||||
let preview;
|
||||
let browser;
|
||||
let context;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
await buildFixture(root, outDir);
|
||||
preview = await runPreview(root);
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const external = [];
|
||||
const osm = [];
|
||||
const localFailures = [];
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
const workerResponses = [];
|
||||
const local = new URL(preview.origin);
|
||||
context.on('requestfailed', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === local.origin) localFailures.push(`request failed ${request.url()}: ${request.failure()?.errorText ?? 'unknown'}`);
|
||||
});
|
||||
context.on('response', (response) => {
|
||||
const url = new URL(response.url());
|
||||
if (url.origin === local.origin && response.status() >= 400) {
|
||||
localFailures.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
if (url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok()) {
|
||||
workerResponses.push(response.url());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error)));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
// This static fixture needs no WebSocket connection. Intercept before any
|
||||
// server connection so a future dependency cannot bypass the HTTP guard.
|
||||
await context.routeWebSocket('**/*', socket => {
|
||||
external.push(socket.url());
|
||||
socket.close();
|
||||
});
|
||||
await context.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
requests.push(url.toString());
|
||||
if (url.origin === local.origin) return route.continue();
|
||||
if (url.hostname === 'tile.openstreetmap.org') {
|
||||
osm.push(url.toString());
|
||||
return route.fulfill({ status: 200, contentType: 'image/png', body: TILE_PNG });
|
||||
}
|
||||
external.push(url.toString());
|
||||
return route.abort('blockedbyclient');
|
||||
});
|
||||
await page.goto(`${preview.origin}/ui/`, { waitUntil: 'domcontentloaded' });
|
||||
assertNoExternal(external);
|
||||
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
||||
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
await page.locator('[data-testid="monaco-file-editor"]').waitFor();
|
||||
await page.locator('.leaflet-container').waitFor();
|
||||
try {
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('.monaco-editor [role="textbox"]') || document.querySelector('[data-testid="monaco-load-error"]')), undefined, { timeout: 45000 });
|
||||
} catch (error) {
|
||||
const editorState = await page.locator('[data-testid="monaco-file-editor"]').evaluate(element => ({ html: element.innerHTML, text: element.textContent }));
|
||||
throw new Error(`Monaco did not mount: ${JSON.stringify({ editorState, localFailures, pageErrors, consoleErrors, requests: requests.slice(-30) })}`, { cause: error });
|
||||
}
|
||||
if (await page.locator('[data-testid="monaco-load-error"]').count() > 0) {
|
||||
throw new Error(`Monaco lazy load failed: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: 45000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const fontFamilies = await page.evaluate(async () => {
|
||||
const names = ['IBM Plex Sans JP', 'IBM Plex Mono', 'JetBrains Mono'];
|
||||
await Promise.all(names.map(name => document.fonts.load(`16px "${name}"`)));
|
||||
return Object.fromEntries(names.map(name => [name, document.fonts.check(`16px "${name}"`)]));
|
||||
});
|
||||
if (Object.values(fontFamilies).some(value => value !== true)) {
|
||||
throw new Error(`font faces did not load: ${JSON.stringify(fontFamilies)}`);
|
||||
}
|
||||
const fontRequests = requests.filter(url => /(?:plex|jetbrains)/i.test(url) && /\.(?:woff2?|ttf)(?:[?#]|$)/i.test(url));
|
||||
if (new Set(fontRequests.map(url => /ibm-plex-sans-jp|ibm-plex-mono|jetbrains-mono/i.exec(url)?.[0])).size < 3) {
|
||||
throw new Error(`expected all three local font families, got: ${fontRequests.join(', ')}`);
|
||||
}
|
||||
|
||||
const editor = page.locator('.monaco-editor [role="textbox"]').first();
|
||||
await editor.waitFor();
|
||||
await editor.focus();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.type('# edited locally\\n');
|
||||
await page.getByTestId('monaco-save').click();
|
||||
// onSave can report persisted content before the child's dirty-state
|
||||
// notification reaches the parent. Wait for both observable outcomes.
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector('[data-testid="save-result"]')?.textContent?.includes('edited locally') &&
|
||||
document.querySelector('[data-testid="dirty-state"]')?.textContent === 'false');
|
||||
|
||||
await page.waitForFunction(() => document.querySelectorAll('.leaflet-marker-icon').length === 2);
|
||||
const markerCount = await page.locator('.leaflet-marker-icon').count();
|
||||
if (markerCount !== 2) throw new Error(`expected two rendered map markers, got ${markerCount}`);
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.leaflet-marker-icon')].every(element => element instanceof HTMLImageElement && element.naturalWidth > 0 && element.naturalHeight > 0), undefined, { timeout: 30000 });
|
||||
const markerSources = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => element.getAttribute('src') ?? ''));
|
||||
const markerUrls = markerSources.map((src) => src.startsWith('data:') ? src : new URL(src, `${local.origin}/ui/`).href);
|
||||
if (markerUrls.some(url => !url.startsWith(`${local.origin}/ui/`) && !url.startsWith('data:image/'))) {
|
||||
throw new Error(`marker image escaped same-origin asset path: ${markerUrls.join(', ')}`);
|
||||
}
|
||||
const markerSizes = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => ({
|
||||
width: element instanceof HTMLImageElement ? element.naturalWidth : 0,
|
||||
height: element instanceof HTMLImageElement ? element.naturalHeight : 0,
|
||||
})));
|
||||
if (markerSizes.some(size => size.width <= 0 || size.height <= 0)) {
|
||||
throw new Error(`marker images did not decode: ${JSON.stringify(markerSizes)}`);
|
||||
}
|
||||
if (osm.length === 0) throw new Error('Leaflet did not request an OSM tile');
|
||||
if (workerResponses.length === 0) {
|
||||
await context.waitForEvent('response', {
|
||||
predicate: response => {
|
||||
const url = new URL(response.url());
|
||||
return url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok();
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
assertNoExternal(external);
|
||||
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
||||
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
const positive = {
|
||||
requests: [...requests], external: [], osm: [...osm], fontRequests,
|
||||
markerSources: markerUrls.map(url => url.startsWith('data:') ? 'data:image' : new URL(url).pathname),
|
||||
};
|
||||
|
||||
// Inject only after the real components finish loading: this exercises
|
||||
// the final guard, including requests that start after DOMContentLoaded.
|
||||
await page.evaluate(url => {
|
||||
document.addEventListener('securitypolicyviolation', event => {
|
||||
if ((event.blockedURI === url || event.blockedURI === new URL(url).origin) && event.effectiveDirective === 'style-src-elem' && event.disposition === 'enforce') {
|
||||
document.body.dataset.cspProbeBlocked = 'true';
|
||||
}
|
||||
});
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.onload = link.onerror = () => { document.body.dataset.cdnProbeFinished = 'true'; };
|
||||
document.head.append(link);
|
||||
}, EXPECTED_CDN_URL);
|
||||
await page.waitForFunction(() => document.body.dataset.cdnProbeFinished === 'true', undefined, { timeout: 10000 });
|
||||
|
||||
let negativeCaught = false;
|
||||
if (cspMode) {
|
||||
await page.waitForFunction(() => document.body.dataset.cspProbeBlocked === 'true', undefined, { timeout: 10000 });
|
||||
assertNoExternal(external);
|
||||
negativeCaught = true;
|
||||
} else try {
|
||||
assertNoExternal(external);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ExternalRequestDetected && external.length === 1 &&
|
||||
external[0] === EXPECTED_CDN_URL)) throw error;
|
||||
negativeCaught = true;
|
||||
}
|
||||
if (!negativeCaught) throw new Error('external CDN negative fixture was not rejected by the browser network guard');
|
||||
return { ...positive, negativeExternalCdnDetected: negativeCaught };
|
||||
} finally {
|
||||
await context?.close();
|
||||
await browser?.close();
|
||||
await closePreview(preview);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const positive = await browserCheck();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
cspEnforced: cspMode,
|
||||
sameOriginRequests: positive.requests.length - positive.external.length - positive.osm.length,
|
||||
localFontFiles: positive.fontRequests.length,
|
||||
osmTilesMocked: positive.osm.length,
|
||||
markerSources: positive.markerSources,
|
||||
negativeExternalCdnDetected: positive.negativeExternalCdnDetected,
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
|
||||
|
||||
describe('untrusted pull request CI', () => {
|
||||
it('does not use a privileged PR trigger or shared npm cache', async () => {
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
expect(workflow).not.toMatch(/\bpull_request_target\s*:/);
|
||||
expect(workflow).not.toMatch(/^\s+cache:\s*npm\s*$/m);
|
||||
expect(workflow).toMatch(/\bpermissions:\s*\n\s+contents:\s*read\b/);
|
||||
});
|
||||
|
||||
it('checks lockfiles before installing without lifecycle scripts', async () => {
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
const policyIndex = workflow.indexOf('node scripts/security/dependency-policy.mjs');
|
||||
const installIndex = workflow.indexOf('npm ci --ignore-scripts');
|
||||
|
||||
expect(policyIndex).toBeGreaterThan(-1);
|
||||
expect(installIndex).toBeGreaterThan(policyIndex);
|
||||
expect(workflow).toContain('npm --prefix ui ci --ignore-scripts');
|
||||
expect(workflow).toContain('node scripts/security/rebuild-native.mjs');
|
||||
expect(workflow).toContain('persist-credentials: false');
|
||||
expect(workflow).not.toMatch(/^\s+npm ci\s*$/m);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const DIGEST_PIN = /^[^\s@]+:[^\s@]+@sha256:[a-f0-9]{64}$/;
|
||||
const EXACT_PYTHON_PIN = /^[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?==[^*<>=!~\s;]+(?:\s*;.+)?$/;
|
||||
|
||||
export function checkContainerPolicy(dockerfile) {
|
||||
const violations = [];
|
||||
for (const [index, line] of dockerfile.split(/\r?\n/).entries()) {
|
||||
const match = line.match(/^\s*FROM\s+(\S+)/i);
|
||||
if (!match || match[1].toLowerCase() === 'scratch') continue;
|
||||
if (!DIGEST_PIN.test(match[1])) {
|
||||
violations.push({ code: 'unpinned-image', line: index + 1, value: match[1] });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `npm ci` / `npm install` in the image must carry `--ignore-scripts`.
|
||||
*
|
||||
* Why this check exists
|
||||
* ---------------------
|
||||
* The builder stage had `--ignore-scripts` from the start; the runtime stage —
|
||||
* the one that actually ships — did not, and it installs `build-essential` on
|
||||
* the same layer. A hijacked dependency's install script would have run there
|
||||
* with a compiler in reach: the most permissive spot in the pipeline. The
|
||||
* design and the PR both claimed install scripts were "off everywhere", so the
|
||||
* gap was invisible in the prose (found reviewing PR #1074).
|
||||
*
|
||||
* Only RUN instructions are inspected, and comment lines are dropped first:
|
||||
* this Dockerfile explains the Playwright bin collision in prose that mentions
|
||||
* `npm ci` several times, and matching those would make the check cry wolf.
|
||||
* Line continuations are joined, because the runtime install is one link in a
|
||||
* long `&&` chain spread over many lines.
|
||||
*/
|
||||
export function checkDockerfileInstallPolicy(dockerfile) {
|
||||
const violations = [];
|
||||
const lines = dockerfile.split(/\r?\n/);
|
||||
let buffer = '';
|
||||
let startLine = 0;
|
||||
let inRun = false;
|
||||
for (const [index, raw] of lines.entries()) {
|
||||
const line = raw.replace(/\s+#.*$/, '');
|
||||
if (/^\s*#/.test(raw)) continue;
|
||||
if (!inRun) {
|
||||
if (!/^\s*RUN\s/i.test(line)) continue;
|
||||
inRun = true;
|
||||
startLine = index + 1;
|
||||
buffer = '';
|
||||
}
|
||||
buffer += line.replace(/\\\s*$/, ' ');
|
||||
if (/\\\s*$/.test(line)) continue;
|
||||
for (const command of buffer.split(/&&|;|\|\|/)) {
|
||||
if (!/\bnpm\b[^|]*\s(?:ci|install|i)(?:\s|$)/.test(command)) continue;
|
||||
if (/--ignore-scripts\b/.test(command)) continue;
|
||||
violations.push({
|
||||
code: 'npm-install-runs-scripts',
|
||||
line: startLine,
|
||||
value: command.trim(),
|
||||
});
|
||||
}
|
||||
buffer = '';
|
||||
inRun = false;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function checkPythonRequirements(requirements) {
|
||||
const violations = [];
|
||||
for (const [index, rawLine] of requirements.split(/\r?\n/).entries()) {
|
||||
const value = rawLine.replace(/\s+#.*$/, '').trim();
|
||||
if (!value || value.startsWith('#')) continue;
|
||||
if (value.startsWith('-') || value.includes(' @ ') || /(?:https?|git\+|file):/i.test(value)) {
|
||||
violations.push({ code: 'non-index-python-dependency', line: index + 1, value });
|
||||
continue;
|
||||
}
|
||||
if (!EXACT_PYTHON_PIN.test(value)) {
|
||||
violations.push({ code: 'unpinned-python-dependency', line: index + 1, value });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
checkContainerPolicy,
|
||||
checkDockerfileInstallPolicy,
|
||||
checkPythonRequirements,
|
||||
} from './container-policy.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
describe('container supply-chain policy', () => {
|
||||
it('rejects base images that are not pinned by digest', () => {
|
||||
expect(checkContainerPolicy('FROM node:22-alpine')).toContainEqual(
|
||||
expect.objectContaining({ code: 'unpinned-image' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts base images pinned by tag and digest', () => {
|
||||
expect(
|
||||
checkContainerPolicy(
|
||||
`FROM node:22-alpine@sha256:${'a'.repeat(64)} AS builder`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['pypdf', 'unpinned-python-dependency'],
|
||||
['pymupdf>=1.24', 'unpinned-python-dependency'],
|
||||
['httpx~=0.28.0', 'unpinned-python-dependency'],
|
||||
['PyYAML==6.0.*', 'unpinned-python-dependency'],
|
||||
['package @ https://example.invalid/package.whl', 'non-index-python-dependency'],
|
||||
])('rejects unsafe requirement %s', (requirement, code) => {
|
||||
expect(checkPythonRequirements(requirement)).toContainEqual(
|
||||
expect.objectContaining({ code }),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an exact index version pin', () => {
|
||||
expect(checkPythonRequirements('PyYAML==6.0.3 # YAML support')).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts every external input in the production container files', () => {
|
||||
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
const requirementFiles = [
|
||||
'runtime/python-requirements.txt',
|
||||
'scripts/x-adapter/requirements.txt',
|
||||
];
|
||||
|
||||
expect(checkContainerPolicy(dockerfile)).toEqual([]);
|
||||
for (const filename of requirementFiles) {
|
||||
const contents = fs.readFileSync(path.join(root, filename), 'utf8');
|
||||
expect(checkPythonRequirements(contents), filename).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install scripts in the image', () => {
|
||||
// 出荷される runtime ステージだけ素の npm ci のままだった(PR #1074 のレビューで発覚)。
|
||||
// しかも build-essential を同じ層で入れているので、乗っ取られた依存の導入時スクリプトが
|
||||
// コンパイラごと使える。設計も PR 本文も「全部止めている」と書いていたので、
|
||||
// 文章からは見えない穴だった。ここで固定する。
|
||||
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
|
||||
it('runs no npm install without --ignore-scripts anywhere in the image', () => {
|
||||
expect(checkDockerfileInstallPolicy(dockerfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['runtime(出荷物)', 'npm ci --omit=dev --ignore-scripts', 'npm ci --omit=dev'],
|
||||
['builder', 'RUN npm ci --ignore-scripts', 'RUN npm ci'],
|
||||
['builder の画面側', 'RUN npm --prefix ui ci --ignore-scripts', 'RUN npm --prefix ui ci'],
|
||||
])('flags %s when --ignore-scripts is dropped', (_label, guarded, bare) => {
|
||||
expect(dockerfile).toContain(guarded);
|
||||
const violations = checkDockerfileInstallPolicy(dockerfile.replace(guarded, bare));
|
||||
expect(violations).toContainEqual(
|
||||
expect.objectContaining({ code: 'npm-install-runs-scripts' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cry wolf over prose that mentions npm ci', () => {
|
||||
// この Dockerfile は Playwright の bin 衝突を長い散文で説明していて、
|
||||
// その中に `npm ci` が何度も出てくる。コメントを拾うと常に赤になる。
|
||||
const violations = checkDockerfileInstallPolicy(
|
||||
['# NOTE: `npm ci` alone does NOT download the browser', 'RUN npm ci --ignore-scripts'].join('\n'),
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the allowlisted native rebuild next to the guarded install', () => {
|
||||
// --ignore-scripts だけ入れて rebuild を忘れると、native が入らないまま出荷される。
|
||||
expect(dockerfile).toContain('node scripts/security/rebuild-native.mjs');
|
||||
expect(dockerfile).toContain('COPY security/install-scripts-allowlist.json');
|
||||
expect(dockerfile).toContain('COPY scripts/security/rebuild-native.mjs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkLockfilePolicy } from './dependency-policy.mjs';
|
||||
import { rebuildArgs, validateRebuildEntries, verify } from './rebuild-native.mjs';
|
||||
|
||||
const registryOrigin = 'https://registry.npmjs.org/';
|
||||
|
||||
describe('dependency policy adversarial cases', () => {
|
||||
it('rejects a non-link package when resolved and integrity are both missing', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': {
|
||||
version: '1.0.0',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
['node_modules/evil'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'missing-resolved',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
expect(violations).toContainEqual({
|
||||
code: 'missing-integrity',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects lookalike registry hostnames', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org.evil.example/evil.tgz',
|
||||
integrity: 'sha512-example',
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'non-registry-url',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
// このテストは名前で path / name / version / integrity の 4 つを縛ると言っている。
|
||||
// ところが実際に起こしていたのは version の食い違いだけで、integrity 照合を
|
||||
// 削っても 35 件すべて緑だった(PR #1074 のレビュー M8)。4 つを別々に起こす。
|
||||
const soundLockfile = (over = {}) => ({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
...over,
|
||||
},
|
||||
},
|
||||
});
|
||||
const soundApproval = (over = {}) => [{
|
||||
path: 'node_modules/native',
|
||||
name: 'native',
|
||||
version: '1.0.0',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
...over,
|
||||
}];
|
||||
|
||||
it('accepts an approval that matches the lockfile on every field', () => {
|
||||
expect(validateRebuildEntries(soundLockfile(), soundApproval())).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects an integrity mismatch even when the version agrees', () => {
|
||||
expect(() => validateRebuildEntries(soundLockfile({ integrity: 'sha512-swapped' }), soundApproval()))
|
||||
.toThrow(/integrity mismatch/i);
|
||||
});
|
||||
|
||||
it('rejects a target the lockfile says has no install script', () => {
|
||||
// 許可一覧に「script を持たない依存」を書けると、rebuild だけが走る経路になる
|
||||
expect(() => validateRebuildEntries(soundLockfile({ hasInstallScript: false }), soundApproval()))
|
||||
.toThrow(/no install script/i);
|
||||
});
|
||||
|
||||
it('rejects a path whose last node_modules segment is not the approved name', () => {
|
||||
// 経路の取り出しは lastIndexOf でなければならない。indexOf に変えると
|
||||
// node_modules/a/node_modules/b が "a/node_modules/b" になり、
|
||||
// 名前の食い違いを見逃す
|
||||
expect(() => validateRebuildEntries(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/outer/node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
soundApproval({ path: 'node_modules/outer/node_modules/native', name: 'outer' }),
|
||||
)).toThrow(/name mismatch/i);
|
||||
});
|
||||
|
||||
it('accepts a nested path when the approved name matches its last segment', () => {
|
||||
expect(validateRebuildEntries(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/outer/node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
soundApproval({ path: 'node_modules/outer/node_modules/native' }),
|
||||
)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a verifier it does not know, instead of silently skipping', () => {
|
||||
// verify を即 return にしても 35 件すべて緑だった(レビュー M7)。
|
||||
// 「実際にデータベースを開いて確かめる」が丸ごと消えても誰も気づかない状態。
|
||||
expect(() => verify('no-such-verifier')).toThrow(/unsupported native rebuild verifier/i);
|
||||
});
|
||||
|
||||
it('actually opens a database for the verifier this repository uses', () => {
|
||||
// 中身が消えたら(即 return になったら)ここが空振りになるので、
|
||||
// 検証が本当に走ったことを、走った先の失敗で見る
|
||||
expect(() => verify('better-sqlite3-memory-db')).not.toThrow();
|
||||
});
|
||||
|
||||
it('binds native rebuild permission to path, name, version, and integrity', () => {
|
||||
const lockfile = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/native': {
|
||||
version: '2.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-2.0.0.tgz',
|
||||
integrity: 'sha512-new',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const approved = [{
|
||||
path: 'node_modules/native',
|
||||
name: 'native',
|
||||
version: '1.0.0',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
}];
|
||||
|
||||
expect(() => validateRebuildEntries(lockfile, approved)).toThrow(/version mismatch/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how npm rebuild is invoked', () => {
|
||||
// 引数の並びに意味がある箇所。PR #1074 のレビューが、`--` を消しても
|
||||
// 35 件すべて緑だったと報告した(M6)。
|
||||
it('separates the package name from flags with --', () => {
|
||||
const args = rebuildArgs('better-sqlite3');
|
||||
expect(args[args.length - 2]).toBe('--');
|
||||
expect(args[args.length - 1]).toBe('better-sqlite3');
|
||||
});
|
||||
|
||||
it('builds from source instead of downloading a prebuilt binary', () => {
|
||||
// 配布物を取ってこないぶん安全側。消すと取得物が 1 つ増える
|
||||
expect(rebuildArgs('better-sqlite3')).toContain('--build-from-source');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { posix as posixPath } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const TRUSTED_REGISTRY_ORIGIN = 'https://registry.npmjs.org/';
|
||||
const DEFAULT_ALLOWLIST_PATH = 'security/install-scripts-allowlist.json';
|
||||
|
||||
export function isAllowedRegistryUrl(resolved, registryOrigin) {
|
||||
try {
|
||||
const url = new URL(resolved);
|
||||
const registry = new URL(registryOrigin);
|
||||
return (
|
||||
url.protocol === registry.protocol &&
|
||||
url.hostname === registry.hostname &&
|
||||
url.port === registry.port &&
|
||||
url.username === '' &&
|
||||
url.password === '' &&
|
||||
url.pathname.startsWith(registry.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// npm はリポジトリ内の workspace を、ダウンロード物ではなくリポジトリ相対パスの
|
||||
// キーで記録する(ルートの package-lock.json なら packages/foo、ui/ 配下からは
|
||||
// ../packages/foo)。レジストリから取得する依存は必ず node_modules/ 区間を含む。
|
||||
function isWorkspaceSourcePath(name) {
|
||||
return !name.startsWith('node_modules/') && !name.includes('/node_modules/');
|
||||
}
|
||||
|
||||
/**
|
||||
* link の指す先が、リポジトリの内側に収まっているか。
|
||||
*
|
||||
* この関数が在る理由
|
||||
* ------------------
|
||||
* link を「同じ lockfile にキーとして在るか」だけで通していた時期がある。その条件は
|
||||
* 歯止めにならない——lockfile を書ける側が link と実体キーの両方を書けるので、
|
||||
* `node_modules/evil -> ../../../../tmp/payload` に同名のキーを併記するだけで、
|
||||
* 取得元・ハッシュ・レジストリ判定のすべてを回避できた(PR #1074 のレビューで実証)。
|
||||
*
|
||||
* そこで、lockfile の置き場所を基準に解決して、リポジトリルートより上へ出ないことを
|
||||
* 確かめる。`ui/package-lock.json` の `../packages/answer-worker-contract` は通り、
|
||||
* それ以上さかのぼるものと絶対パスは止まる。
|
||||
*
|
||||
* @param resolved lockfile に書かれた link の指す先(リポジトリ相対)
|
||||
* @param lockfileDir lockfile が置かれたディレクトリ(リポジトリルート基準・posix)
|
||||
*/
|
||||
export function isLinkInsideRepo(resolved, lockfileDir) {
|
||||
if (typeof resolved !== 'string' || resolved === '') return false;
|
||||
// 絶対パスと Windows のドライブ表記は、リポジトリ相対ではないので受け付けない。
|
||||
if (posixPath.isAbsolute(resolved) || /^[A-Za-z]:/.test(resolved)) return false;
|
||||
const joined = posixPath.normalize(posixPath.join(lockfileDir, resolved));
|
||||
return joined !== '..' && !joined.startsWith('../');
|
||||
}
|
||||
|
||||
export function checkLockfilePolicy(
|
||||
lockfile,
|
||||
allowlist,
|
||||
registryOrigin = TRUSTED_REGISTRY_ORIGIN,
|
||||
lockfileDir = '.',
|
||||
) {
|
||||
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
|
||||
throw new Error('dependency policy requires npm lockfile version 3 with a packages map');
|
||||
}
|
||||
if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string')) {
|
||||
throw new Error('install script allowlist must be an array of lockfile package paths');
|
||||
}
|
||||
|
||||
const allowed = new Set(allowlist);
|
||||
const violations = [];
|
||||
|
||||
for (const [name, entry] of Object.entries(lockfile.packages)) {
|
||||
if (!name || !entry || typeof entry !== 'object') continue;
|
||||
|
||||
// link は同じ lockfile 内に実体キーがある場合だけ認める。実体を持たない link は
|
||||
// 追跡できない外部の入力を指すため、従来どおり拒否する。
|
||||
// 実体キーが在ることに加えて、指す先がリポジトリの内側であることまで見る。
|
||||
// 片方だけでは、lockfile を書ける側が両方を握って素通りできる。
|
||||
const linkTargetIsInLockfile =
|
||||
entry.link === true &&
|
||||
typeof entry.resolved === 'string' &&
|
||||
Object.hasOwn(lockfile.packages, entry.resolved) &&
|
||||
isLinkInsideRepo(entry.resolved, lockfileDir);
|
||||
const isWorkspaceSource = isWorkspaceSourcePath(name);
|
||||
|
||||
if (entry.link && !linkTargetIsInLockfile) {
|
||||
violations.push({ code: 'unsupported-link', name });
|
||||
} else if (!entry.link && !isWorkspaceSource && !entry.resolved) {
|
||||
violations.push({ code: 'missing-resolved', name });
|
||||
} else if (
|
||||
!entry.link &&
|
||||
entry.resolved &&
|
||||
!isAllowedRegistryUrl(entry.resolved, registryOrigin)
|
||||
) {
|
||||
violations.push({ code: 'non-registry-url', name });
|
||||
}
|
||||
|
||||
// workspace の実体にも install script 検査は残す(多層防御)。
|
||||
if (entry.hasInstallScript && !allowed.has(name)) {
|
||||
violations.push({ code: 'unapproved-install-script', name });
|
||||
}
|
||||
|
||||
// リポジトリ内 workspace はダウンロードしないので integrity を持たない。
|
||||
if (!entry.link && !isWorkspaceSource && !entry.integrity) {
|
||||
violations.push({ code: 'missing-integrity', name });
|
||||
}
|
||||
}
|
||||
|
||||
// 許可一覧の腐敗検知。install script を持たなくなった依存が一覧に残ると、
|
||||
// 将来その依存が再び script を持ったときに無審査で通ってしまう。
|
||||
for (const path of allowlist) {
|
||||
const entry = lockfile.packages[path];
|
||||
if (!entry || !entry.hasInstallScript) {
|
||||
violations.push({ code: 'stale-allowlist-entry', name: path });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
export async function main(args) {
|
||||
if (args.length === 0) {
|
||||
throw new Error('usage: dependency-policy.mjs <package-lock.json> [...]');
|
||||
}
|
||||
|
||||
const policy = await readJson(DEFAULT_ALLOWLIST_PATH);
|
||||
let failed = false;
|
||||
|
||||
for (const lockfilePath of args) {
|
||||
const lockfile = await readJson(lockfilePath);
|
||||
const allowlist = policy.lockfiles?.[lockfilePath];
|
||||
if (!Array.isArray(allowlist)) {
|
||||
throw new Error(`no install script allowlist is defined for ${lockfilePath}`);
|
||||
}
|
||||
|
||||
const lockfileDir = posixPath.dirname(lockfilePath.split('\\').join('/'));
|
||||
const violations = checkLockfilePolicy(lockfile, allowlist, TRUSTED_REGISTRY_ORIGIN, lockfileDir);
|
||||
for (const violation of violations) {
|
||||
failed = true;
|
||||
process.stderr.write(`${lockfilePath}: ${violation.code}: ${violation.name}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkLockfilePolicy, isAllowedRegistryUrl, main } from './dependency-policy.mjs';
|
||||
|
||||
const registryOrigin = 'https://registry.npmjs.org/';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const readJson = (relative) =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
||||
|
||||
describe('checkLockfilePolicy', () => {
|
||||
it('rejects remote tarballs, missing integrity, and unapproved install scripts', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': {
|
||||
resolved: 'https://evil.example/evil-1.0.0.tgz',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toEqual([
|
||||
{ code: 'non-registry-url', name: 'node_modules/evil' },
|
||||
{ code: 'unapproved-install-script', name: 'node_modules/evil' },
|
||||
{ code: 'missing-integrity', name: 'node_modules/evil' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts an exact lockfile path that is explicitly allowlisted', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/native-package': {
|
||||
resolved: 'https://registry.npmjs.org/native-package/-/native-package-1.0.0.tgz',
|
||||
integrity: 'sha512-example',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
['node_modules/native-package'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not let a package-name allowlist approve a nested copy implicitly', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/parent/node_modules/native-package': {
|
||||
resolved: 'https://registry.npmjs.org/native-package/-/native-package-2.0.0.tgz',
|
||||
integrity: 'sha512-example',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
['node_modules/native-package'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'unapproved-install-script',
|
||||
name: 'node_modules/parent/node_modules/native-package',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported lockfile formats instead of silently passing', () => {
|
||||
expect(() =>
|
||||
checkLockfilePolicy({ lockfileVersion: 2, packages: {} }, [], registryOrigin),
|
||||
).toThrow(/lockfile version 3/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('this repository', () => {
|
||||
// 許可一覧を実物の lockfile と突き合わせる。install script を持つ依存が増えた/
|
||||
// 場所が変わった/一覧から消えた場合、CI の前に npm test で落ちる。
|
||||
it.each(['package-lock.json', 'ui/package-lock.json'])(
|
||||
'satisfies the install script policy for %s',
|
||||
(lockfilePath) => {
|
||||
const policy = readJson('security/install-scripts-allowlist.json');
|
||||
// link の判定は lockfile の置き場所を基準に解決する。CI の入口
|
||||
// (dependency-policy.mjs の main)と同じ値を渡さないと、ui/ の
|
||||
// ../packages/... が「リポジトリ外」と判定されてしまう。
|
||||
const lockfileDir = path.posix.dirname(lockfilePath);
|
||||
const violations = checkLockfilePolicy(
|
||||
readJson(lockfilePath),
|
||||
policy.lockfiles[lockfilePath],
|
||||
registryOrigin,
|
||||
lockfileDir,
|
||||
);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('in-repo workspace entries', () => {
|
||||
const workspaceLockfile = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/@scope/contract': { resolved: 'packages/contract', link: true },
|
||||
'packages/contract': { name: '@scope/contract', version: '0.0.0' },
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts a link whose target is a workspace source in the same lockfile', () => {
|
||||
expect(checkLockfilePolicy(workspaceLockfile, [], registryOrigin)).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a parent-relative workspace path as npm writes it from a sub-package', () => {
|
||||
// ui/package-lock.json から見た ../packages/... の形。lockfile が ui/ に在るので
|
||||
// 1 つ上へ戻ってもリポジトリの内側に収まる。
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/@scope/contract': { resolved: '../packages/contract', link: true },
|
||||
'../packages/contract': { name: '@scope/contract', version: '0.0.0' },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
'ui',
|
||||
);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
// ここから 3 件は、実体キーを併記すれば外部を指す link が素通りした穴を塞ぐもの
|
||||
// (PR #1074 のレビューが実証)。lockfile を書ける側が link と実体キーの両方を
|
||||
// 握れるので、「同じ lockfile に在るか」だけでは歯止めにならない。
|
||||
it('rejects a link that escapes the repository even when its target key is present', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': { resolved: '../../../../tmp/evil-payload', link: true },
|
||||
'../../../../tmp/evil-payload': { name: 'evil', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
|
||||
});
|
||||
|
||||
it('rejects an absolute link target even when its key is present', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': { resolved: '/etc/evil', link: true },
|
||||
'/etc/evil': { name: 'evil', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
|
||||
});
|
||||
|
||||
it('rejects a sub-package link that climbs past the repository root', () => {
|
||||
// ui/ から 2 つ上がればリポジトリの外。1 つまでなら正当(上のテスト)。
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': { resolved: '../../outside/evil', link: true },
|
||||
'../../outside/evil': { name: 'evil', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
'ui',
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
|
||||
});
|
||||
|
||||
it('still rejects a link whose target is absent from the lockfile', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/@scope/contract': { resolved: '../../outside/contract', link: true },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'unsupported-link',
|
||||
name: 'node_modules/@scope/contract',
|
||||
});
|
||||
});
|
||||
|
||||
it('still rejects an install script on a workspace source that is not allowlisted', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'packages/contract': { name: '@scope/contract', version: '0.0.0', hasInstallScript: true },
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'unapproved-install-script',
|
||||
name: 'packages/contract',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowlist rot', () => {
|
||||
it('reports an allowlisted path that no longer exists in the lockfile', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{ lockfileVersion: 3, packages: {} },
|
||||
['node_modules/removed'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'stale-allowlist-entry',
|
||||
name: 'node_modules/removed',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an allowlisted package that no longer has an install script', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/calm': {
|
||||
resolved: 'https://registry.npmjs.org/calm/-/calm-1.0.0.tgz',
|
||||
integrity: 'sha512-example',
|
||||
},
|
||||
},
|
||||
},
|
||||
['node_modules/calm'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'stale-allowlist-entry',
|
||||
name: 'node_modules/calm',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// レジストリ判定は 6 つの比較でできている。PR #1074 のレビューが、そのうち
|
||||
// 4 つ(protocol / port / userinfo / pathname)をそれぞれ削っても 35 件すべて
|
||||
// 緑のままだと報告した。効いていたのは hostname 一致だけ。判定そのものは
|
||||
// 正しく動いているので、守るものが無いという指摘。1 つずつ固定する。
|
||||
describe('registry url check — each comparison earns its keep', () => {
|
||||
const ok = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz';
|
||||
|
||||
it('accepts the real registry', () => {
|
||||
expect(isAllowedRegistryUrl(ok, registryOrigin)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['protocol — 平文に落とす', 'http://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'],
|
||||
['port — 別のポートへ向ける', 'https://registry.npmjs.org:8443/left-pad/-/left-pad-1.3.0.tgz'],
|
||||
['userinfo — 利用者名だけ埋める', 'https://[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
|
||||
['userinfo — 合言葉だけ埋める', 'https://:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
|
||||
['userinfo — 両方埋める', 'https://user:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
|
||||
['userinfo — @ で別ホストへ逃がす', 'https://[email protected]/left-pad.tgz'],
|
||||
['hostname — 似た名前の別ホスト', 'https://registry.npmjs.org.evil.example/left-pad.tgz'],
|
||||
['hostname — 部分一致を狙う', 'https://evil-registry.npmjs.org.example/left-pad.tgz'],
|
||||
])('rejects %s', (_label, url) => {
|
||||
expect(isAllowedRegistryUrl(url, registryOrigin)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a registry origin with a path prefix when the url leaves it', () => {
|
||||
// pathname 比較を消すと、別のパス配下でも通ってしまう
|
||||
expect(isAllowedRegistryUrl('https://example.test/other/x.tgz', 'https://example.test/scoped/')).toBe(false);
|
||||
expect(isAllowedRegistryUrl('https://example.test/scoped/x.tgz', 'https://example.test/scoped/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the entry point CI actually runs', () => {
|
||||
// main は CI が叩く唯一の入口なのに、どのテストからも呼ばれていなかった
|
||||
// (PR #1074 のレビュー P2-2)。純関数だけ固めて配線が無テスト、の典型。
|
||||
it('throws when a lockfile has no allowlist defined', async () => {
|
||||
await expect(main(['no-such-lockfile-in-the-policy.json'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('requires at least one lockfile path', async () => {
|
||||
await expect(main([])).rejects.toThrow(/usage/);
|
||||
});
|
||||
|
||||
it('leaves the exit code alone when this repository passes', async () => {
|
||||
const before = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
await main(['package-lock.json', 'ui/package-lock.json']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
process.exitCode = before;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkLockfilePolicy } from './dependency-policy.mjs';
|
||||
import { validateRebuildEntries } from './rebuild-native.mjs';
|
||||
|
||||
const lockedNative = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/better-sqlite3': {
|
||||
version: '12.6.2',
|
||||
resolved: 'https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function approval(name) {
|
||||
return [{
|
||||
path: 'node_modules/better-sqlite3',
|
||||
name,
|
||||
version: '12.6.2',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
}];
|
||||
}
|
||||
|
||||
describe('native rebuild adversarial cases', () => {
|
||||
it('rejects a package name that does not match the approved lockfile path', () => {
|
||||
expect(() => validateRebuildEntries(lockedNative, approval('sharp'))).toThrow(
|
||||
/name mismatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects npm option injection as a package name', () => {
|
||||
expect(() => validateRebuildEntries(lockedNative, approval('--prefix=/tmp/evil'))).toThrow(
|
||||
/invalid package name|name mismatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lockfile links because this repository has no workspaces', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': { link: true },
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'unsupported-link',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
it('can be imported when a Node eval has no script argv', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-e', "import('./scripts/security/rebuild-native.mjs')"],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' },
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const LOCKFILE_PATH = 'package-lock.json';
|
||||
const POLICY_PATH = 'security/install-scripts-allowlist.json';
|
||||
const SUPPORTED_VERIFIERS = new Set(['better-sqlite3-memory-db']);
|
||||
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
||||
|
||||
function packageNameFromPath(path) {
|
||||
const marker = "node_modules/";
|
||||
const index = path.lastIndexOf(marker);
|
||||
return index === -1 ? "" : path.slice(index + marker.length);
|
||||
}
|
||||
|
||||
export function validateRebuildEntries(lockfile, approvedEntries) {
|
||||
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
|
||||
throw new Error('native rebuild requires npm lockfile version 3');
|
||||
}
|
||||
if (!Array.isArray(approvedEntries)) {
|
||||
throw new Error('native rebuild allowlist must be an array');
|
||||
}
|
||||
|
||||
return approvedEntries.map((approval) => {
|
||||
for (const field of ['path', 'name', 'version', 'integrity', 'verifier']) {
|
||||
if (typeof approval?.[field] !== 'string' || approval[field] === '') {
|
||||
throw new Error(`native rebuild approval is missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (!SUPPORTED_VERIFIERS.has(approval.verifier)) {
|
||||
throw new Error(`unsupported native rebuild verifier: ${approval.verifier}`);
|
||||
}
|
||||
|
||||
if (!PACKAGE_NAME.test(approval.name)) {
|
||||
throw new Error("invalid package name for native rebuild: " + approval.name);
|
||||
}
|
||||
if (packageNameFromPath(approval.path) !== approval.name) {
|
||||
throw new Error("native rebuild name mismatch for " + approval.path);
|
||||
}
|
||||
|
||||
const locked = lockfile.packages[approval.path];
|
||||
if (!locked) throw new Error(`native rebuild path missing from lockfile: ${approval.path}`);
|
||||
if (!locked.hasInstallScript) {
|
||||
throw new Error(`native rebuild target has no install script: ${approval.path}`);
|
||||
}
|
||||
if (locked.version !== approval.version) {
|
||||
throw new Error(`native rebuild version mismatch for ${approval.path}`);
|
||||
}
|
||||
if (locked.integrity !== approval.integrity) {
|
||||
throw new Error(`native rebuild integrity mismatch for ${approval.path}`);
|
||||
}
|
||||
|
||||
return { ...approval };
|
||||
});
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
env: { ...process.env, npm_config_audit: 'false' },
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `npm rebuild` に渡す引数。
|
||||
*
|
||||
* `--build-from-source` は、配布済みバイナリを取ってこさせず必ず手元で組ませる
|
||||
* ための指定(取得物を 1 つ減らすので安全側)。CI の runner にコンパイラ・make・
|
||||
* python3 が要るようになる代わりなので、消すときはそこまで見ること。
|
||||
*
|
||||
* `--` は、名前が `-` で始まっていても旗として解釈させないための区切り。名前は
|
||||
* すでに PACKAGE_NAME で縛ってあるので二重の守りだが、縛りのほうが緩んだときに
|
||||
* ここだけで止まる形にしておく。
|
||||
*/
|
||||
export function rebuildArgs(name) {
|
||||
return ['rebuild', '--build-from-source', '--foreground-scripts', '--no-audit', '--', name];
|
||||
}
|
||||
|
||||
export function verify(verifier) {
|
||||
if (verifier === 'better-sqlite3-memory-db') {
|
||||
run(process.execPath, [
|
||||
'-e',
|
||||
"const Database=require('better-sqlite3'); new Database(':memory:').close()",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`unsupported native rebuild verifier: ${verifier}`);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const [lockfile, policy] = await Promise.all([
|
||||
readFile(LOCKFILE_PATH, 'utf8').then(JSON.parse),
|
||||
readFile(POLICY_PATH, 'utf8').then(JSON.parse),
|
||||
]);
|
||||
const approved = validateRebuildEntries(lockfile, policy.rebuild?.[LOCKFILE_PATH]);
|
||||
|
||||
for (const entry of approved) {
|
||||
run('npm', rebuildArgs(entry.name));
|
||||
verify(entry.verifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
|
||||
|
||||
describe('CI workflow semantic security policy', () => {
|
||||
it('pins actions, minimizes permissions, and leaves no checkout credential', async () => {
|
||||
const source = await readFile(workflowPath, 'utf8');
|
||||
const workflow = parse(source);
|
||||
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' });
|
||||
expect(workflow.on).toHaveProperty('pull_request');
|
||||
expect(source).not.toContain('${{ secrets.');
|
||||
|
||||
for (const job of Object.values(workflow.jobs)) {
|
||||
expect(job.permissions ?? workflow.permissions).toEqual({ contents: 'read' });
|
||||
for (const step of job.steps ?? []) {
|
||||
if (!step.uses) continue;
|
||||
expect(step.uses).toMatch(/^[^@\s]+@[a-f0-9]{40}$/);
|
||||
expect(step.uses).not.toMatch(/(?:^|\/)cache(?:\/(?:restore|save))?@/);
|
||||
expect(step.with ?? {}).not.toHaveProperty('cache');
|
||||
if (step.uses.startsWith('actions/checkout@')) {
|
||||
expect(step.with?.['persist-credentials']).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not invoke repository package hooks before policy or native rebuild', async () => {
|
||||
const source = await readFile(workflowPath, 'utf8');
|
||||
|
||||
expect(source).toContain(
|
||||
'node scripts/security/dependency-policy.mjs package-lock.json ui/package-lock.json',
|
||||
);
|
||||
expect(source).toContain('node scripts/security/rebuild-native.mjs');
|
||||
expect(source).not.toContain('npm run security:dependency-policy');
|
||||
expect(source).not.toContain('npm run ci:rebuild-native');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// 起動に失敗したとき、その理由が画面に出ることを守るテスト (#1235)。
|
||||
//
|
||||
// 起動を断る判定は 3 行以上の本文で「なぜ止めたか」と「どう直すか」を書く。
|
||||
// ところが致命的な例外は `src/main.ts` の最上位ハンドラが `err.stack` ごと
|
||||
// 1 レコードで書き出すので、記録の並びは【本文】→【呼び出し位置の羅列】になる。
|
||||
// `scripts/server.sh` が末尾 5 行しか出さないと、出るのは呼び出し位置だけで、
|
||||
// 肝心の本文は画面から溢れる。直し方が書いてあるのに管理者には見えない。
|
||||
//
|
||||
// だからここで守るのは「行数の数字」ではなく、**本文が収まる幅が残っていること**。
|
||||
// 末尾の行数を減らす変更は、ここで落ちる。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** 起動失敗の案内は最長で 4 行。加えてスタックの見出しと呼び出し位置が続く。 */
|
||||
const MIN_TAIL_LINES = 20;
|
||||
|
||||
describe('起動失敗時の出力 (#1235)', () => {
|
||||
it('server.sh の起動失敗は、案内の本文が収まる行数を出す', () => {
|
||||
const sh = readFileSync(join(repoRoot, 'scripts', 'server.sh'), 'utf8');
|
||||
const failureBlock = sh.slice(sh.indexOf('Server failed to start'));
|
||||
const tail = /tail\s+(?:-n\s*)?-?(\d+)\s+"\$LOG_FILE"/.exec(failureBlock);
|
||||
expect(tail).not.toBeNull();
|
||||
expect(Number(tail![1])).toBeGreaterThanOrEqual(MIN_TAIL_LINES);
|
||||
});
|
||||
|
||||
it('起動を断る本文とスタックの合計が、その行数に収まる', () => {
|
||||
// 本文の行数は実物から数える。文面を伸ばしたときに、この歯止めが
|
||||
// 一緒に見直されるようにするため。
|
||||
const src = readFileSync(join(repoRoot, 'src', 'worker-bootstrap.ts'), 'utf8');
|
||||
// throw new Error(`...`) のテンプレートリテラルに含まれる改行の数。
|
||||
const refusals = src.match(/throw new Error\(\s*`[\s\S]*?\)\s*;/g) ?? [];
|
||||
expect(refusals.length).toBeGreaterThan(0);
|
||||
const longest = Math.max(...refusals.map((r) => (r.match(/\\n/g) ?? []).length + 1));
|
||||
// スタックの見出し 1 行 + 呼び出し位置の余裕。
|
||||
expect(longest + 10).toBeLessThanOrEqual(MIN_TAIL_LINES);
|
||||
});
|
||||
});
|
||||
+14
-1
@@ -113,7 +113,12 @@ do_start() {
|
||||
echo " If a previous run was not stopped cleanly, stop it first to avoid port/DB conflicts."
|
||||
fi
|
||||
|
||||
# The log captures stdout, which on a fresh non-loopback install includes the
|
||||
# generated admin password (#1024). Keep it owner-only — the password file it
|
||||
# is printed alongside is 0600, and a world-readable copy in logs/ would undo
|
||||
# that. Applied every start so an existing log gets tightened too.
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
touch "$LOG_FILE" && chmod 600 "$LOG_FILE"
|
||||
|
||||
echo "Checking runtime dependencies..."
|
||||
"$SCRIPT_DIR/prepare.sh"
|
||||
@@ -121,6 +126,11 @@ do_start() {
|
||||
echo "Building..."
|
||||
npm run build --silent 2>&1 | tail -1
|
||||
|
||||
if [[ "${AAO_MODE:-worker}" == "worker" ]]; then
|
||||
echo "Running database migrations..."
|
||||
node dist/scripts/migrate-db.js
|
||||
fi
|
||||
|
||||
echo "Starting server on port $PORT..."
|
||||
# AAO is a single binary with two modes (dist/main.js dispatches):
|
||||
# AAO_MODE=worker (default) — full orchestrator (DB + bridge API + workers)
|
||||
@@ -145,7 +155,10 @@ do_start() {
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "Server failed to start. Check $LOG_FILE"
|
||||
tail -5 "$LOG_FILE"
|
||||
# 20 lines, not 5 (#1235): a fatal error is logged as one multi-line record
|
||||
# whose stack trace lands *after* the explanation, so a short tail shows the
|
||||
# call sites and cuts off the part that says how to fix it.
|
||||
tail -20 "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
+73
-16
@@ -2,16 +2,17 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== noVNC CAPTCHA 解決機能セットアップ ==="
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# 1. システム依存パッケージのインストール
|
||||
echo ""
|
||||
echo "[1/5] システムパッケージをインストール中..."
|
||||
echo "[1/6] システムパッケージをインストール中..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq x11vnc websockify xvfb
|
||||
|
||||
# 2. 確認
|
||||
echo ""
|
||||
echo "[2/5] インストール確認..."
|
||||
echo "[2/6] インストール確認..."
|
||||
for cmd in Xvfb x11vnc websockify; do
|
||||
if which "$cmd" > /dev/null 2>&1; then
|
||||
echo " ✓ $cmd: $(which "$cmd")"
|
||||
@@ -23,21 +24,26 @@ done
|
||||
|
||||
# 3. @novnc/novnc npm パッケージ確認
|
||||
echo ""
|
||||
echo "[3/5] @novnc/novnc パッケージ確認..."
|
||||
if node -e "require.resolve('@novnc/novnc/package.json')" 2>/dev/null; then
|
||||
echo "[3/6] @novnc/novnc パッケージ確認..."
|
||||
if (cd "$PROJECT_DIR" && node -e 'require.resolve("@novnc/novnc/package.json")') 2>/dev/null; then
|
||||
echo " ✓ @novnc/novnc: インストール済み"
|
||||
else
|
||||
echo " → npm install @novnc/novnc ..."
|
||||
npm install @novnc/novnc
|
||||
echo " → lockfileを検査して安全に依存関係を復元中..."
|
||||
(
|
||||
cd "$PROJECT_DIR"
|
||||
node scripts/security/dependency-policy.mjs package-lock.json ui/package-lock.json
|
||||
npm ci --ignore-scripts --no-audit
|
||||
node scripts/security/rebuild-native.mjs
|
||||
)
|
||||
fi
|
||||
|
||||
# 4. noVNC スタンドアロン (vnc.html を含む Web 配布物) を vendor/noVNC に配置
|
||||
# npm の @novnc/novnc は lib のみで vnc.html を含まないため、
|
||||
# Browser タブの iframe 用に GitHub から tarball を取得する。
|
||||
echo ""
|
||||
echo "[4/5] noVNC Web 配布物 (vendor/noVNC) 確認..."
|
||||
NOVNC_VERSION="${NOVNC_VERSION:-1.6.0}"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
echo "[4/6] noVNC Web 配布物 (vendor/noVNC) 確認..."
|
||||
NOVNC_VERSION=1.6.0
|
||||
NOVNC_SHA256=5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa
|
||||
VENDOR_DIR="$PROJECT_DIR/vendor"
|
||||
NOVNC_DIR="$VENDOR_DIR/noVNC"
|
||||
if [ -f "$NOVNC_DIR/vnc.html" ]; then
|
||||
@@ -45,17 +51,56 @@ if [ -f "$NOVNC_DIR/vnc.html" ]; then
|
||||
else
|
||||
echo " → noVNC v$NOVNC_VERSION を取得中..."
|
||||
mkdir -p "$VENDOR_DIR"
|
||||
TMP_TGZ=""
|
||||
TMP_NOVNC_DIR=""
|
||||
NOVNC_BACKUP_DIR=""
|
||||
cleanup_novnc() {
|
||||
exit_status=$?
|
||||
trap - EXIT INT TERM
|
||||
if [ -n "$NOVNC_BACKUP_DIR" ] && [ ! -e "$NOVNC_DIR" ] && [ -e "$NOVNC_BACKUP_DIR" ]; then
|
||||
mv "$NOVNC_BACKUP_DIR" "$NOVNC_DIR"
|
||||
fi
|
||||
if [ -n "$NOVNC_BACKUP_DIR" ] && [ -e "$NOVNC_BACKUP_DIR" ]; then
|
||||
rm -rf "$NOVNC_BACKUP_DIR"
|
||||
fi
|
||||
[ -z "$TMP_TGZ" ] || rm -f "$TMP_TGZ"
|
||||
[ -z "$TMP_NOVNC_DIR" ] || rm -rf "$TMP_NOVNC_DIR"
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap cleanup_novnc EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
TMP_TGZ="$(mktemp -t novnc.XXXXXX.tar.gz)"
|
||||
trap 'rm -f "$TMP_TGZ"' EXIT
|
||||
TMP_NOVNC_DIR="$(mktemp -d "$VENDOR_DIR/.noVNC.XXXXXX")"
|
||||
if ! curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" -o "$TMP_TGZ"; then
|
||||
echo " ✗ noVNC tarball の取得に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$NOVNC_DIR"
|
||||
mkdir -p "$NOVNC_DIR"
|
||||
tar -xzf "$TMP_TGZ" -C "$NOVNC_DIR" --strip-components=1
|
||||
if ! printf '%s %s\n' "$NOVNC_SHA256" "$TMP_TGZ" | sha256sum -c -; then
|
||||
echo " ✗ noVNC tarball のSHA-256検証に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -xzf "$TMP_TGZ" -C "$TMP_NOVNC_DIR" --strip-components=1
|
||||
if [ ! -f "$TMP_NOVNC_DIR/vnc.html" ]; then
|
||||
echo " ✗ vnc.html が見つかりません。配置に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
NOVNC_BACKUP_DIR=""
|
||||
if [ -e "$NOVNC_DIR" ]; then
|
||||
NOVNC_BACKUP_DIR="$(mktemp -d "$VENDOR_DIR/.noVNC.backup.XXXXXX")"
|
||||
rmdir "$NOVNC_BACKUP_DIR"
|
||||
mv "$NOVNC_DIR" "$NOVNC_BACKUP_DIR"
|
||||
fi
|
||||
if ! mv "$TMP_NOVNC_DIR" "$NOVNC_DIR"; then
|
||||
if [ -n "$NOVNC_BACKUP_DIR" ]; then
|
||||
mv "$NOVNC_BACKUP_DIR" "$NOVNC_DIR"
|
||||
fi
|
||||
echo " ✗ noVNC vendorの置換に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$NOVNC_BACKUP_DIR" ] || rm -rf "$NOVNC_BACKUP_DIR"
|
||||
rm -f "$TMP_TGZ"
|
||||
trap - EXIT
|
||||
trap - EXIT INT TERM
|
||||
if [ -f "$NOVNC_DIR/vnc.html" ]; then
|
||||
echo " ✓ vendor/noVNC/vnc.html: 配置完了"
|
||||
else
|
||||
@@ -64,9 +109,21 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. config.yaml に display_mode 設定を追加(まだなければ)
|
||||
# 5. ファイル選択の画面を禁じる管理ポリシー
|
||||
# noVNC の画面は人がブラウザをそのまま触れる。ファイルを選ぶボタンから
|
||||
# サーバー上のファイルを持ち出せてしまうので、選択画面そのものを禁じる (#1148)。
|
||||
echo ""
|
||||
echo "[5/5] config.yaml 確認..."
|
||||
echo "[5/6] ファイル選択の画面を禁じるポリシーを配置..."
|
||||
if bash "$(dirname "$0")/install-browser-policy.sh"; then
|
||||
echo " ✓ 配置しました"
|
||||
else
|
||||
echo " ⚠ 配置に失敗しました。noVNC の画面からファイル選択画面を開けてしまいます。" >&2
|
||||
echo " 後から 'sudo scripts/install-browser-policy.sh' で入れてください。" >&2
|
||||
fi
|
||||
|
||||
# 6. config.yaml に display_mode 設定を追加(まだなければ)
|
||||
echo ""
|
||||
echo "[6/6] config.yaml 確認..."
|
||||
CONFIG="$(cd "$(dirname "$0")/.." && pwd)/config.yaml"
|
||||
if [ -f "$CONFIG" ]; then
|
||||
if grep -qE "display_mode|captcha_solve" "$CONFIG"; then
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
const setupScript = fs.readFileSync(
|
||||
path.join(root, 'scripts/setup-novnc.sh'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function readPins(source) {
|
||||
return {
|
||||
version: source.match(/\bNOVNC_VERSION(?:=|:-)([0-9.]+)/)?.[1],
|
||||
sha256: source.match(/\bNOVNC_SHA256(?:=|:-)([a-f0-9]{64})/)?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
describe('noVNC bootstrap integrity', () => {
|
||||
it('keeps Dockerfile and setup script pins identical', () => {
|
||||
expect(readPins(setupScript)).toEqual(readPins(dockerfile));
|
||||
expect(readPins(setupScript).sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('checks SHA-256 before replacing the existing vendor directory', () => {
|
||||
const checksum = setupScript.indexOf('sha256sum -c -');
|
||||
const replacement = setupScript.indexOf('mv "$TMP_NOVNC_DIR" "$NOVNC_DIR"');
|
||||
|
||||
expect(checksum).toBeGreaterThan(-1);
|
||||
expect(replacement).toBeGreaterThan(checksum);
|
||||
});
|
||||
it('pins the npm fallback and disables lifecycle scripts in the project', () => {
|
||||
const projectDir = setupScript.indexOf('PROJECT_DIR=');
|
||||
const npmCheck = setupScript.indexOf('require.resolve("@novnc/novnc/package.json")');
|
||||
const policy = setupScript.indexOf("node scripts/security/dependency-policy.mjs");
|
||||
const npmInstall = setupScript.indexOf("npm ci --ignore-scripts --no-audit");
|
||||
const rebuild = setupScript.indexOf("node scripts/security/rebuild-native.mjs");
|
||||
|
||||
expect(projectDir).toBeGreaterThan(-1);
|
||||
expect(npmCheck).toBeGreaterThan(projectDir);
|
||||
expect(policy).toBeGreaterThan(npmCheck);
|
||||
expect(npmInstall).toBeGreaterThan(policy);
|
||||
expect(rebuild).toBeGreaterThan(npmInstall);
|
||||
expect(setupScript).not.toContain('npm install @novnc/novnc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Known uses of a Tailwind opacity modifier on a color that has no alpha slot",
|
||||
"(see scripts/check-tailwind-opacity.mjs). These produce no CSS at all.",
|
||||
"This file exists so the check fails on NEW ones only. Do not add to it by",
|
||||
"hand: fix the class, then run `npm run check:tailwind-opacity -- --update`."
|
||||
],
|
||||
"total": 106,
|
||||
"files": 52,
|
||||
"classes": 46,
|
||||
"entries": [
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "dark:bg-slate-900/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/AskUserCard.tsx",
|
||||
"class": "dark:bg-slate-900/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ChatComposerPanel.tsx",
|
||||
"class": "bg-canvas/90",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ChatMessage.tsx",
|
||||
"class": "dark:bg-slate-900/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ChatPane.tsx",
|
||||
"class": "bg-canvas/80",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/DelegateLiveConsole.tsx",
|
||||
"class": "bg-canvas/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/DelegateLiveConsole.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementGroup.tsx",
|
||||
"class": "hover:bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "bg-canvas/80",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "bg-canvas/90",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "bg-slate-400/25",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "border-slate-400/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/MovementMap.tsx",
|
||||
"class": "dark:border-slate-700/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
|
||||
"class": "bg-slate-50/80",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
|
||||
"class": "dark:bg-slate-500/15",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
|
||||
"class": "dark:bg-slate-900/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ToolCallsSection.tsx",
|
||||
"class": "bg-surface/70",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/chat/ToolCallsSection.tsx",
|
||||
"class": "hover:bg-surface/50",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/CreateTaskDialogAdvancedSection.tsx",
|
||||
"class": "bg-slate-50/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "bg-slate-50/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "border-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "dark:bg-slate-800/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "dark:bg-slate-900/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/create/PromptCoachPanel.tsx",
|
||||
"class": "hover:bg-accent/5",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/dashboard/WorkerStatusWidget.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/detail/ContinueWithPieceDialog.tsx",
|
||||
"class": "focus:ring-accent/30",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/detail/DetailPanel.tsx",
|
||||
"class": "border-hairline/70",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/detail/tabs/console/ConsoleTabStrip.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/detail/tabs/console/ConsoleTabStrip.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/detail/tabs/ConsoleTab.tsx",
|
||||
"class": "bg-surface/10",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/DictionaryPopover.tsx",
|
||||
"class": "hover:bg-accent-soft/80",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/FileDetailList.tsx",
|
||||
"class": "border-hairline/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/FileHistoryModal.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/FileSectionTabs.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/FileToolbar.tsx",
|
||||
"class": "bg-canvas/85",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/files/MoveTargetDialog.tsx",
|
||||
"class": "divide-hairline/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/list/FilterBar.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/list/RailPanel.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/list/RailPanel.tsx",
|
||||
"class": "ring-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/notifications/NotificationRow.tsx",
|
||||
"class": "bg-accent/5",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/GatewayKeyUsagePanel.tsx",
|
||||
"class": "bg-accent/70",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/MemoryLearningForm.tsx",
|
||||
"class": "hover:bg-surface/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/OrgsForm.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SkillsForm.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SkillsForm.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshAuditLog.tsx",
|
||||
"class": "bg-surface/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshAuditLog.tsx",
|
||||
"class": "hover:bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
|
||||
"class": "border-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGrantsForm.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGrantsForm.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshGrantsForm.tsx",
|
||||
"class": "border-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/settings/SshMasterKeyRotationForm.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/setup/SetupWizard.tsx",
|
||||
"class": "focus:ring-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/ChatDetailSplit.tsx",
|
||||
"class": "group-hover:bg-slate-500/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/ChatDetailSplit.tsx",
|
||||
"class": "hover:bg-slate-300/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
|
||||
"class": "bg-slate-400/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
|
||||
"class": "focus-visible:bg-slate-400/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
|
||||
"class": "hover:bg-slate-300/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/PythonPackagesPanel.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceChatRail.tsx",
|
||||
"class": "dark:hover:bg-slate-700/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceToolSettings.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/spaces/SpaceWebhookSettings.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/AgentsMdPanel.tsx",
|
||||
"class": "bg-surface-2/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/McpPanel.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/McpPanel.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
|
||||
"class": "bg-accent/10",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
|
||||
"class": "bg-surface/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
|
||||
"class": "hover:bg-surface/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshConnectionForm.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshConnectionsPanel.tsx",
|
||||
"class": "border-accent/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshConnectionsPanel.tsx",
|
||||
"class": "border-accent/40",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshHostKeyDialog.tsx",
|
||||
"class": "bg-surface/50",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshPublicKeyDialog.tsx",
|
||||
"class": "bg-surface/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/components/userfolder/SshPublicKeyDialog.tsx",
|
||||
"class": "bg-surface/40",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/lib/workspaceDirs.ts",
|
||||
"class": "dark:bg-slate-500/15",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/lib/workspaceDirs.ts",
|
||||
"class": "dark:bg-slate-600/30",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/pages/AdminEventLogPage.tsx",
|
||||
"class": "hover:bg-surface-2/60",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"file": "ui/src/pages/SchedulesPage.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"file": "ui/src/pages/UsersPage.tsx",
|
||||
"class": "border-accent/60",
|
||||
"count": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// UI の型検査が「関所」として残り続けることを守るテスト (#1001)。
|
||||
//
|
||||
// issue #1001 の原因は個別の型エラーではなく、**UI 側に型検査の関所が無かった**
|
||||
// こと。vite build は型を見ず、vitest は esbuild でトランスパイルするだけなので、
|
||||
// 誰も止めないまま 12 ファイル 37 件が溜まった
|
||||
// (origin/main = 34aa673e を素の状態でチェックアウトして `cd ui && npx tsc
|
||||
// --noEmit` した実測。issue 本文の 30 件は起票時点の値)。
|
||||
//
|
||||
// 型エラーを潰すだけでは同じことが必ず再発する。だからこのテストが守るのは
|
||||
// 「今エラーが 0 件であること」ではなく、**検査が実行される経路が残っていること**。
|
||||
// 関所を外す変更は、ここで落ちる。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = (p: string) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
|
||||
|
||||
/**
|
||||
* ES2022 で標準ライブラリに入ったメソッドだけを使う検査用の小片 (#1314)。
|
||||
* ui/tsconfig.json の lib がこれらを含んでいれば通り、含んでいなければ TS2550。
|
||||
*/
|
||||
const ES2022_PROBE_SOURCE = [
|
||||
'const numbers = [1, 2, 3];',
|
||||
'export const last = numbers.at(-1);',
|
||||
"export const head = 'abc'.at(0);",
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* ReadableStream を直接 for await で回す小片 (#1314)。
|
||||
*
|
||||
* この構文を成立させているのは lib.dom.asynciterable の 1 枚だけ。lib を明示した
|
||||
* ときに書き起こし損ねやすい枚数で、落とすと「触っていないファイルが落ちる」が
|
||||
* #1314 とは逆向きに起きる(今度は原因がこちらの設定変更になる)。
|
||||
*/
|
||||
const DOM_ASYNC_ITERABLE_PROBE_SOURCE = [
|
||||
'export async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {',
|
||||
' let total = 0;',
|
||||
' for await (const chunk of stream) total += chunk.byteLength;',
|
||||
' return total;',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* ブラウザに無いもの——Node の型が漏れているかを見るための小片 3 つ (#1316)。
|
||||
*
|
||||
* 親ディレクトリの node_modules/@types が画面側の検査に混ざっていた頃は、3 つとも
|
||||
* 診断ゼロで通っていた。とくに Buffer はブラウザに存在しないので、検査を素通りした
|
||||
* まま出荷され、実行時に壊れる。1 つずつ分けてあるのは、どれが復活したかを
|
||||
* 失敗メッセージだけで見分けられるようにするため。
|
||||
*/
|
||||
const NODE_TIMER_PROBE_SOURCE = 'export const handle: NodeJS.Timeout = setTimeout(() => {}, 0);';
|
||||
const NODE_PROCESS_PROBE_SOURCE = 'export const flag = process.env.SOME_FLAG;';
|
||||
const NODE_BUFFER_PROBE_SOURCE = "export const bytes = Buffer.from('hi');";
|
||||
|
||||
/** 上の 3 つの対照。ブラウザにあるものは通る、を同じ道具立てで確かめる。 */
|
||||
const BROWSER_PROBE_SOURCE = [
|
||||
'export const timer = window.setTimeout(() => {}, 0);',
|
||||
'export const text = new TextDecoder().decode(new Uint8Array([104, 105]));',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* tsc が実際に組むファイル一覧を返す。
|
||||
*
|
||||
* 設定の字面(include / exclude / strict)を見るだけでは穴が残る。include は
|
||||
* "src" のままで exclude に "src/**\/*.test.tsx" を足すと、227 本のテストが
|
||||
* 黙って検査対象から外れる。解決済みの一覧を見れば、どちらの経路で抜いても落ちる。
|
||||
*/
|
||||
function resolveUiProgram(configName: string): string[] {
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
|
||||
expect(raw.error).toBeUndefined();
|
||||
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.options.strict).toBe(true);
|
||||
return parsed.fileNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* ui/tsconfig.json の compilerOptions だけで小さなコードを検査し、診断を返す。
|
||||
*
|
||||
* types を空にしてあるのが肝。TypeScript は node_modules/@types を親ディレクトリへ
|
||||
* 遡って自動で取り込むので、既定のままだとリポジトリ直下の @types/* が画面側の
|
||||
* 検査に混ざる。空にすると「画面側の設定だけで成り立っているか」を見られる。
|
||||
*/
|
||||
function diagnoseWithUiOptions(
|
||||
source: string,
|
||||
overrides: ts.CompilerOptions = {},
|
||||
): readonly ts.Diagnostic[] {
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
|
||||
expect(raw.error).toBeUndefined();
|
||||
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
...overrides,
|
||||
types: [],
|
||||
noEmit: true,
|
||||
noResolve: true,
|
||||
};
|
||||
const probePath = join(uiDir, '__lib-probe__.ts');
|
||||
const host = ts.createCompilerHost(options, true);
|
||||
const readOriginal = host.readFile.bind(host);
|
||||
const getOriginal = host.getSourceFile.bind(host);
|
||||
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
|
||||
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
|
||||
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
|
||||
f === probePath
|
||||
? ts.createSourceFile(f, source, langVersion, true)
|
||||
: getOriginal(f, langVersion, onError, shouldCreate);
|
||||
|
||||
const program = ts.createProgram([probePath], options, host);
|
||||
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した設定ファイルの compilerOptions を**そのまま**使って小片を検査する (#1316)。
|
||||
*
|
||||
* 上の diagnoseWithUiOptions との違いは 2 点あり、どちらも外せない。
|
||||
*
|
||||
* 1. types を上書きしない。あちらは types: [] を強制するので、tsconfig.json が
|
||||
* types を書いていようがいまいが同じ結果になる——つまり #1316 の検体を
|
||||
* あちらに載せると、設定を直す前から通ってしまう(空振り)。ここは設定の
|
||||
* 実物を見る必要があるので、上書きしない。
|
||||
* 2. noResolve を立てない。noResolve は types で名指しした型定義の読み込みまで
|
||||
* 止めてしまい、types: ["node"] を書いても Node の型が入らなくなる(実測)。
|
||||
* それでは tsconfig.typecheck.json 側の検査が意味を失う。
|
||||
*
|
||||
* 小片は ui/ 直下に置いたことにする。@types の探索は置き場所から始まるので、
|
||||
* 本物のファイルと同じ条件になる。
|
||||
*/
|
||||
function diagnoseWithConfigAsWritten(
|
||||
configName: string,
|
||||
source: string,
|
||||
overrides: ts.CompilerOptions = {},
|
||||
): readonly ts.Diagnostic[] {
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
|
||||
expect(raw.error).toBeUndefined();
|
||||
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
|
||||
const options: ts.CompilerOptions = { ...parsed.options, ...overrides, noEmit: true };
|
||||
const probePath = join(uiDir, '__node-probe__.ts');
|
||||
const host = ts.createCompilerHost(options, true);
|
||||
const readOriginal = host.readFile.bind(host);
|
||||
const getOriginal = host.getSourceFile.bind(host);
|
||||
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
|
||||
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
|
||||
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
|
||||
f === probePath
|
||||
? ts.createSourceFile(f, source, langVersion, true)
|
||||
: getOriginal(f, langVersion, onError, shouldCreate);
|
||||
|
||||
const program = ts.createProgram([probePath], options, host);
|
||||
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
|
||||
}
|
||||
|
||||
/** ディスク上に実在する ui/src の TypeScript ファイル一覧(設定の字面を通さずに数える)。 */
|
||||
function sourceFilesOnDisk(): string[] {
|
||||
return ts.sys.readDirectory(join(repoRoot, 'ui', 'src'), ['.ts', '.tsx'], ['node_modules'], undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定ファイルを**本物のプログラムとして組み**、読み込まれたファイルを全部返す (#1316)。
|
||||
*
|
||||
* resolveUiProgram が返すのは根っこ(include / exclude で選ばれたファイル)だけで、
|
||||
* そこから import や参照でたぐり寄せられた .d.ts は含まれない。types: [] が守るのは
|
||||
* まさにその「たぐり寄せ」の側なので、根っこだけ見ていても関所の状態は分からない。
|
||||
*
|
||||
* 第 5 引数に設定ファイルのパスを渡すのが肝。渡さないと typeRoots の探索が
|
||||
* プロセスの作業ディレクトリ(=リポジトリ直下)から始まり、@types/node が
|
||||
* ui/node_modules ではなく直下から拾われる。本物の `cd ui && tsc` と条件が変わって
|
||||
* しまい、ui/package.json の宣言が効いているかを見られなくなる(実測)。
|
||||
*/
|
||||
function realProgramFiles(configName: string): string[] {
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const configPath = join(uiDir, configName);
|
||||
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
expect(raw.error).toBeUndefined();
|
||||
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir, undefined, configPath);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
const program = ts.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
|
||||
return program.getSourceFiles().map((f) => f.fileName);
|
||||
}
|
||||
|
||||
/** 上の一覧のうち、Node の型定義(@types/node 配下)だけを取り出す。 */
|
||||
function nodeTypeFiles(files: readonly string[]): string[] {
|
||||
return files.filter((f) => /[\\/]@types[\\/]node[\\/]/.test(f));
|
||||
}
|
||||
|
||||
describe('UI 型検査のゲート (#1001)', () => {
|
||||
it('ui のビルドは型検査を通ってからバンドルする', () => {
|
||||
const build: string = readJson('ui/package.json').scripts.build;
|
||||
expect(build).toContain('tsc --noEmit');
|
||||
// 順序も守る: バンドルが先だと、型エラーでも成果物が出てしまう。
|
||||
expect(build.indexOf('tsc --noEmit')).toBeLessThan(build.indexOf('vite build'));
|
||||
});
|
||||
|
||||
it('型検査だけを単体で呼べる', () => {
|
||||
// CI と開発者が「バンドルせずに型だけ見る」ために必要。
|
||||
expect(readJson('ui/package.json').scripts.typecheck).toContain('tsc -p tsconfig.typecheck.json');
|
||||
expect(readJson('package.json').scripts['typecheck:ui']).toContain('ui run typecheck');
|
||||
});
|
||||
|
||||
it('CI に独立した型検査ステップがある(コメントアウトも不可)', () => {
|
||||
// build に含まれてはいるが、独立ステップにしておくと「型で落ちた」のか
|
||||
// 「バンドルで落ちた」のかがログを読まずに分かる。
|
||||
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
|
||||
const live = ci.split('\n').filter(line => !line.trim().startsWith('#'));
|
||||
expect(live.some(line => line.includes('npm run typecheck:ui'))).toBe(true);
|
||||
// ステップ単位で失敗を無視されると、赤いはずの CI が緑になる。
|
||||
expect(live.some(line => line.includes('continue-on-error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('型検査の失敗を握り潰していない', () => {
|
||||
// `tsc --noEmit || true` と書けば、上の 3 つはすべて通ったまま関所が
|
||||
// 無力化する。実行されていることと、落ちること(=失敗が伝播すること)は別。
|
||||
//
|
||||
// 見るのは呼び出し連鎖の全段。ui のスクリプトだけを見ていると、CI の
|
||||
// `run:` かルートの中継スクリプトに 1 つ足すだけで素通りする(実測で確認)。
|
||||
const swallowed = /\|\|\s*(true|:|exit\s+0)|;\s*(true|exit\s+0)\s*$/;
|
||||
const uiScripts = readJson('ui/package.json').scripts;
|
||||
const rootScripts = readJson('package.json').scripts;
|
||||
for (const script of [uiScripts.build, uiScripts.typecheck, rootScripts['typecheck:ui']]) {
|
||||
expect(script).not.toMatch(swallowed);
|
||||
expect(script).not.toContain('--noEmit false');
|
||||
}
|
||||
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
|
||||
for (const line of ci.split('\n')) {
|
||||
if (line.trim().startsWith('#') || !line.includes('typecheck:ui')) continue;
|
||||
expect(line).not.toMatch(swallowed);
|
||||
}
|
||||
});
|
||||
|
||||
it('ui の TypeScript は、テストも E2E も設定ファイルも検査対象に入っている', () => {
|
||||
// テスト側の型ずれ(実装のプロパティ追加にモックが追随していない等)は
|
||||
// 実装と現実がずれ始めた合図。検査対象から外すと、その合図が消える。
|
||||
//
|
||||
// include / strict の形だけを見ると素通りする穴がある: include は "src" の
|
||||
// ままで exclude に "src/**/*.test.tsx" を足すと、227 本のテストが黙って
|
||||
// 検査対象から外れる。だから設定の字面ではなく、tsc が実際に組む
|
||||
// ファイル一覧を見る(exclude であれ include であれ、抜けたら落ちる)。
|
||||
const files = resolveUiProgram('tsconfig.typecheck.json');
|
||||
const checked = files.filter((f) => /\.test\.tsx?$/.test(f));
|
||||
expect(checked.length).toBeGreaterThan(0);
|
||||
// 実装コードも当然入っていること(テストだけ検査しても意味がない)。
|
||||
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
|
||||
// E2E と ui/ 直下の設定ファイル。ここが抜けていたのが元の状態で、
|
||||
// 「UI に関所が無い」という本件の主題からすると範囲が中途半端だった。
|
||||
expect(files.some((f) => /\/e2e\/.*\.spec\.ts$/.test(f))).toBe(true);
|
||||
expect(files.some((f) => /\/e2e-auth\/.*\.spec\.ts$/.test(f))).toBe(true);
|
||||
expect(files.some((f) => /\/ui\/playwright\.config\.ts$/.test(f))).toBe(true);
|
||||
});
|
||||
|
||||
it('ビルド用の設定は出荷物だけを見る(製品ビルドを E2E とテストに依存させない)', () => {
|
||||
// build は Dockerfile の npm run build:ui から呼ばれる製品ビルドの経路。
|
||||
// ここに E2E を混ぜると、製品イメージのビルドが @playwright/test などの
|
||||
// devDependency に依存する。検査の網は広く、ビルドの依存は狭く。
|
||||
//
|
||||
// テストとテスト基盤 (src/test/) も外してある (#1316)。この設定は types: [] で
|
||||
// Node の型を断っており、node:fs や global を正当に使う src のテスト (15 本) とは
|
||||
// 両立しない。src/test/ を外す理由はもう 1 つあって、dom-setup.ts が読む vitest は
|
||||
// ui/package.json に書かれておらず、リポジトリ直下から解決されていた——直下の
|
||||
// node_modules から入るファイルが 109 本から 60 本 (TypeScript の標準ライブラリ
|
||||
// だけ) に減る。「外したぶんが検査されなくなる」抜け穴は下の 1 件が塞ぐ。
|
||||
const files = resolveUiProgram('tsconfig.json');
|
||||
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
|
||||
expect(files.filter((f) => /\.test\.tsx?$/.test(f))).toEqual([]);
|
||||
expect(files.filter((f) => /\/src\/test\//.test(f))).toEqual([]);
|
||||
expect(files.some((f) => /\/e2e(-auth)?\//.test(f))).toBe(false);
|
||||
});
|
||||
|
||||
it('ui/src のファイルは 1 本残らず型検査用の設定に入っている (#1316)', () => {
|
||||
// #1001 の元の見張りは「ビルド用の設定にテストが 1 本以上入っている」を見て
|
||||
// いた。#1316 でビルド用からテストとテスト基盤を外したので、その担保をこちらへ
|
||||
// 移す。数ではなくディスク上の実ファイルとの差分で見るぶん、元より強い——
|
||||
// exclude を 1 行足して 1 ディレクトリ丸ごと外す、といった抜き方でも落ちるし、
|
||||
// ビルド用から今後さらに何かを外しても、こちらに入っていなければ落ちる。
|
||||
const onDisk = sourceFilesOnDisk();
|
||||
expect(onDisk.length).toBeGreaterThan(0);
|
||||
const checked = new Set(resolveUiProgram('tsconfig.typecheck.json'));
|
||||
expect(onDisk.filter((f) => !checked.has(f))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI の標準ライブラリ設定 (#1314)', () => {
|
||||
it('画面側の設定だけで ES2022 のメソッドが解決できる', () => {
|
||||
// 親ディレクトリの @types を一切使わない条件で at() が引ける、が合格条件。
|
||||
// lib の明示を消すと target (ES2020) から標準ライブラリが決まるので、ここが
|
||||
// TS2550 で落ちる。
|
||||
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE);
|
||||
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('lib を ES2020 相当に戻すと、この検査は実際に落ちる', () => {
|
||||
// 上の検査が「何を書いても通る空振り」でないことの担保。ここが通らなくなったら
|
||||
// 検査そのものが壊れている(親の @types に頼る経路が復活した等)。
|
||||
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE, {
|
||||
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
|
||||
});
|
||||
expect(diagnostics.map((d) => d.code)).toContain(2550);
|
||||
});
|
||||
|
||||
it('ReadableStream を直接 for await で回せる(DOM.AsyncIterable が効いている)', () => {
|
||||
// 直す前の暗黙値 lib.es2020.full は 6 枚を参照していた。lib を手で書き起こす
|
||||
// ときに落ちやすいのがこの 1 枚で、落ちても既存コードは無傷のまま通る——
|
||||
// 画面側のストリーム処理は getReader() 経由で、for await している 3 か所は
|
||||
// 相手が非同期ジェネレータだから。次に誰かが本文を直接 for await した瞬間に
|
||||
// 初めて落ちる。その「次の人」の代わりに、ここで先に踏んでおく。
|
||||
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE);
|
||||
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
|
||||
});
|
||||
|
||||
it('DOM.AsyncIterable を外すと、この検査は実際に落ちる', () => {
|
||||
// 空振りの担保。1 枚だけ抜いた対照で、他は本物の設定のまま。
|
||||
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE, {
|
||||
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
|
||||
});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('lib は tsconfig に明示されている(target 任せにしない)', () => {
|
||||
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
|
||||
const lib: unknown = raw.config?.compilerOptions?.lib;
|
||||
expect(Array.isArray(lib)).toBe(true);
|
||||
const entries = (lib as string[]).map((l) => l.toLowerCase());
|
||||
expect(entries).toContain('dom');
|
||||
expect(entries).toContain('dom.asynciterable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('画面側に Node の型を混ぜない (#1316)', () => {
|
||||
// 直す前は 3 つとも診断ゼロで通っていた。原因は個別の型エラーではなく、
|
||||
// TypeScript が node_modules/@types を親ディレクトリへ遡って自動で取り込む
|
||||
// 性質——ui/package.json は Node の型定義を依存に持っていないのに、リポジトリ
|
||||
// 直下の @types/node がそっくり画面側の検査に入っていた。
|
||||
//
|
||||
// 関所は ui/tsconfig.json 側にしか置けない。ui/tsconfig.typecheck.json の側は
|
||||
// e2e が better-sqlite3 を読み、その .d.ts の `/// <reference types="node" />`
|
||||
// が Node の型をプログラム全体へ呼び戻すので、types をどう書いても src で
|
||||
// Buffer が通ってしまう(--explainFiles で確認)。
|
||||
|
||||
const buildProbes: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['タイマーの戻り値に NodeJS.Timeout を当てる', NODE_TIMER_PROBE_SOURCE, 2503],
|
||||
['環境変数を読む', NODE_PROCESS_PROBE_SOURCE, 2591],
|
||||
['バッファを作る', NODE_BUFFER_PROBE_SOURCE, 2591],
|
||||
];
|
||||
|
||||
it.each(buildProbes)('出荷物の設定では落ちる: %s', (_name, source, code) => {
|
||||
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', source);
|
||||
expect(diagnostics.map((d) => d.code)).toContain(code);
|
||||
});
|
||||
|
||||
it('出荷物の設定でも、ブラウザにあるものは通る', () => {
|
||||
// 上の 3 件が「何を書いても落ちる」空振りでないことの対照。同じ道具立てで、
|
||||
// window.setTimeout と TextDecoder は診断ゼロであること。
|
||||
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', BROWSER_PROBE_SOURCE);
|
||||
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(buildProbes)('型検査用の設定では通る: %s', (_name, source) => {
|
||||
// vite.config.ts と playwright の設定、e2e、src のテストはどれも Node の上で
|
||||
// 動く。こちらまで断つと、それらが落ちる。
|
||||
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source);
|
||||
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(buildProbes)('型検査用の設定から types を空にすると、実際に落ちる: %s', (_name, source, code) => {
|
||||
// 上の 3 件の空振り担保。types: ["node"] の 1 行が効いていることを見る。
|
||||
//
|
||||
// ここを本物の tsc(npx tsc -p tsconfig.typecheck.json)で確かめようとすると
|
||||
// 空振りする: types を消しても、better-sqlite3 の三連スラッシュ参照が Node の
|
||||
// 型を呼び戻すので通ってしまう。小片 1 つだけのプログラムなら、Node の型の
|
||||
// 出どころは types しかない。
|
||||
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source, { types: [] });
|
||||
expect(diagnostics.map((d) => d.code)).toContain(code);
|
||||
});
|
||||
|
||||
it('出荷物の本物のプログラムに、Node の型定義が 1 本も入っていない (#1316)', () => {
|
||||
// ここが**関所の本体**。上の 3 件は小片 1 本だけの小さなプログラムを組んで
|
||||
// いるので、実質「設定の字面に types: [] と書いてあるか」しか見ていない。
|
||||
//
|
||||
// types: [] が止めるのは自動取り込みだけで、プログラム内のどれかの .d.ts が
|
||||
// `/// <reference types="node" />` を書いていればそこから Node の型が入る。
|
||||
// 実際に ui/src へその 1 行を書いたファイルを 1 本置くと、`cd ui && npx tsc
|
||||
// --noEmit` は Buffer を診断ゼロで通し、字面を見る見張りは全部緑のままだった
|
||||
// (実測)。しかも実在の経路がある: ui/src の .d.ts がビルドツールの入口を
|
||||
// 参照していて、そのパッケージは別の場所ですでに Node を参照している。版が
|
||||
// 上がって入口側が Node を引いた瞬間、関所は黙って開く。
|
||||
//
|
||||
// だから本物のプログラムを組んで、読み込まれた @types/node のファイル数を見る。
|
||||
// 参照が 1 本入るだけで 0 → 63 に跳ねるので、差ははっきり出る(実測)。
|
||||
const leaked = nodeTypeFiles(realProgramFiles('tsconfig.json'));
|
||||
expect(leaked).toEqual([]);
|
||||
});
|
||||
|
||||
it('型検査用の本物のプログラムには Node の型定義が入っている(上の見張りの対照)', () => {
|
||||
// 上の 1 件が「どう組んでも 0 本になる空振り」でないことの担保。同じ道具立てで
|
||||
// types: ["node"] の側を組むと、Node の型定義が実際に読み込まれる。
|
||||
//
|
||||
// 出どころも見る。ui/package.json の devDependencies に @types/node を足した
|
||||
// 意味は、参照先を ui 自身の node_modules に固定すること。リポジトリ直下から
|
||||
// 拾われていたら宣言は飾りなので、そこまで含めて落とす。
|
||||
const loaded = nodeTypeFiles(realProgramFiles('tsconfig.typecheck.json'));
|
||||
expect(loaded.length).toBeGreaterThan(0);
|
||||
const uiOwned = join(repoRoot, 'ui', 'node_modules');
|
||||
expect(loaded.filter((f) => !f.startsWith(uiOwned))).toEqual([]);
|
||||
});
|
||||
|
||||
it('親の型定義の自動取り込みは、設定に書いて断ってある', () => {
|
||||
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
|
||||
const uiDir = join(repoRoot, 'ui');
|
||||
const build = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
|
||||
expect(build.config?.compilerOptions?.types).toEqual([]);
|
||||
const typecheck = ts.readConfigFile(join(uiDir, 'tsconfig.typecheck.json'), ts.sys.readFile);
|
||||
expect(typecheck.config?.compilerOptions?.types).toEqual(['node']);
|
||||
});
|
||||
|
||||
it('Node の型定義は ui の依存として宣言され、直下と同じ版の範囲を書いている', () => {
|
||||
// 宣言しないと、参照先はリポジトリ直下の node_modules を運任せに拾うことに戻る。
|
||||
//
|
||||
// ここが見ているのは**範囲の文字列が一致していること**だけで、実際に入る版まで
|
||||
// は見ていない。固定ファイルが直下と ui で別なので、解決される版はすでにずれて
|
||||
// いる(直下 20.19.37 / ui 20.19.43)。同じ大版の中の差で、1 つのプログラムに
|
||||
// 入る型定義は片方だけなので実害は無い。
|
||||
//
|
||||
// 大版をそろえる意味は #1314 のときとは変わった。#1314 で踏んだ「直下が 20 の
|
||||
// まま ui だけ新しくなると出荷物が落ちる」形は、ui/tsconfig.json が types: [] に
|
||||
// なった時点で出荷物の側からは消えている。いま残る意味は、画面側の検査とサーバー
|
||||
// 側の Node の版をそろえておくこと。将来 Node を 22 に上げるとき、直下だけ上げた
|
||||
// 瞬間にこのテストが落ちる——意図した動作で、「ui も一緒に上げろ」の合図。
|
||||
const uiRange: unknown = readJson('ui/package.json').devDependencies['@types/node'];
|
||||
const rootRange: unknown = readJson('package.json').devDependencies['@types/node'];
|
||||
expect(typeof uiRange).toBe('string');
|
||||
expect(uiRange).toBe(rootRange);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,15 @@
|
||||
// Validates ui/src/content/help/*.md frontmatter + structural invariants.
|
||||
// Exits non-zero on any error. Run before `vite build`.
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { join, dirname, resolve, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HELP_DIR = resolve(__dirname, '../ui/src/content/help');
|
||||
// 検証するフォルダ。引数で差し替えられるのはテスト用(scripts/validate-help-docs.test.ts)。
|
||||
const HELP_DIR = process.argv[2]
|
||||
? resolve(process.cwd(), process.argv[2])
|
||||
: resolve(__dirname, '../ui/src/content/help');
|
||||
// `yaml` lives in ui/node_modules (the build runs `node ../scripts/...` from ui/).
|
||||
// Resolve it relative to ui/ so this script works regardless of cwd.
|
||||
let parseYaml;
|
||||
@@ -20,30 +23,41 @@ try {
|
||||
}
|
||||
const CATEGORIES = ['basic', 'advanced', 'admin'];
|
||||
|
||||
// KEEP IN SYNC with ui/src/lib/help.ts slugify()
|
||||
function slugify(text) {
|
||||
const s = text.replace(/<[^>]*>/g, '').trim().toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
|
||||
return s || 'section';
|
||||
// 見出し → 飛び先の名前(アンカー)の規則は ui/src/lib/help-slug.ts が唯一の出どころ。
|
||||
// 画面が実際に付ける id と同じものをここでも使うため、規則を写さず読み込む (#1274)。
|
||||
// Node は 22.18 以降 .ts をそのまま読める(型を剥がして実行する)。
|
||||
let slugify, collectHeadingIds;
|
||||
try {
|
||||
({ slugify, collectHeadingIds } = await import(
|
||||
pathToFileURL(resolve(__dirname, '../ui/src/lib/help-slug.ts')).href
|
||||
));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'help-docs validation: cannot load ui/src/lib/help-slug.ts — needs Node >= 22.18 ' +
|
||||
'(TypeScript type stripping) and `npm --prefix ui install`\n ' + (e?.message ?? e),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function splitFrontmatter(raw) {
|
||||
const n = raw.replace(/^/, '').replace(/\r\n/g, '\n');
|
||||
const m = /^---\n([\s\S]*?)\n---\n?/.exec(n);
|
||||
if (!m) return { frontmatter: '', body: n };
|
||||
return { frontmatter: m[1], body: n.slice(m[0].length) };
|
||||
if (!m) return { normalized: n, frontmatter: '', body: n };
|
||||
return { normalized: n, frontmatter: m[1], body: n.slice(m[0].length) };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const ids = new Map();
|
||||
const orders = new Map();
|
||||
/** file -> { normalized, body }。リンクの検証で使う。 */
|
||||
const docs = new Map();
|
||||
|
||||
const files = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
|
||||
if (files.length === 0) errors.push('no .md files found in help dir');
|
||||
|
||||
for (const file of files) {
|
||||
const raw = readFileSync(join(HELP_DIR, file), 'utf8');
|
||||
const { frontmatter, body } = splitFrontmatter(raw);
|
||||
const { normalized, frontmatter, body } = splitFrontmatter(raw);
|
||||
if (!frontmatter) { errors.push(`${file}: missing frontmatter block`); continue; }
|
||||
let data;
|
||||
try { data = parseYaml(frontmatter); } catch (e) { errors.push(`${file}: YAML error ${e.message}`); continue; }
|
||||
@@ -76,10 +90,68 @@ for (const file of files) {
|
||||
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
|
||||
}
|
||||
|
||||
// Relative markdown links that point at local files must resolve.
|
||||
for (const m of body.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
|
||||
const target = resolve(HELP_DIR, m[1].split('#')[0]);
|
||||
if (!existsSync(target)) errors.push(`${file}: broken relative link ${m[1]}`);
|
||||
docs.set(file, { normalized, body });
|
||||
}
|
||||
|
||||
// --- Links -----------------------------------------------------------------
|
||||
// ファイルの存在だけでなく、`#` から後ろ(飛び先の見出し)も突き合わせる。
|
||||
// 見出しの文言を書き換えたのにリンク側が追いついていない、を落とすため (#1274)。
|
||||
// 別のページ宛て(`](./xx.md#…)`)と同じページ内(`](#…)`)の両方を見る。
|
||||
// 片方だけ見張ると「見出しリンクは見張られている」という誤った前提ができる。
|
||||
|
||||
/** その本文を描画したときに実際に付く見出し id。ファイル単位で 1 度だけ数える。 */
|
||||
const headingIdCache = new Map();
|
||||
function headingIdsOf(file) {
|
||||
let ids = headingIdCache.get(file);
|
||||
if (!ids) {
|
||||
ids = new Set(collectHeadingIds(docs.get(file).body));
|
||||
headingIdCache.set(file, ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 文字位置 → 1 始まりの行番号(元ファイルの行と一致させるため normalized 全体で数える)。 */
|
||||
function lineAt(text, index) {
|
||||
let line = 1;
|
||||
for (let i = 0; i < index; i++) if (text[i] === '\n') line++;
|
||||
return line;
|
||||
}
|
||||
|
||||
function decodeFragment(fragment) {
|
||||
try { return decodeURIComponent(fragment); } catch { return fragment; }
|
||||
}
|
||||
|
||||
for (const [file, { normalized }] of docs) {
|
||||
// href の切り出しは以前と同じ(`)` まで)。狭めると、これまで拾えていた
|
||||
// 書き損じ(空白入りのパスなど)を見落とす。
|
||||
for (const m of normalized.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
|
||||
const href = m[1];
|
||||
const line = lineAt(normalized, m.index);
|
||||
const where = `${file}:${line}: ${m[0]}`;
|
||||
const hashAt = href.indexOf('#');
|
||||
const path = hashAt === -1 ? href : href.slice(0, hashAt);
|
||||
const fragment = hashAt === -1 ? '' : decodeFragment(href.slice(hashAt + 1));
|
||||
|
||||
// 宛先のファイルが在ること。
|
||||
const target = resolve(HELP_DIR, path);
|
||||
if (!existsSync(target)) { errors.push(`${where} — target file not found`); continue; }
|
||||
if (!fragment) continue;
|
||||
|
||||
const targetFile = relative(HELP_DIR, target);
|
||||
if (!docs.has(targetFile)) continue; // ヘルプ以外のファイル(見出しを数えない)
|
||||
if (!headingIdsOf(targetFile).has(fragment)) {
|
||||
errors.push(`${where} — no heading '#${fragment}' in ${targetFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 同じページ内のリンク(`](#見出し)`)。飛び先は自分自身の見出し。
|
||||
for (const m of normalized.matchAll(/\]\((#[^)]+)\)/g)) {
|
||||
const fragment = decodeFragment(m[1].slice(1));
|
||||
if (!fragment) continue;
|
||||
const line = lineAt(normalized, m.index);
|
||||
if (!headingIdsOf(file).has(fragment)) {
|
||||
errors.push(`${file}:${line}: ${m[0]} — no heading '#${fragment}' in ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// ヘルプの「飛び先の見出し」検証が本当に効くことを守るテスト (#1274)。
|
||||
//
|
||||
// 守る対象は「いま何本壊れているか」ではなく、**壊したら赤くなる経路が残って
|
||||
// いること**。`#` から後ろを見ない作りに戻したら、ここが緑のままになる——それを
|
||||
// 防ぐために、同じ本文で「合っている / 合っていない」を撮り比べる。
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const script = join(repoRoot, 'scripts', 'validate-help-docs.mjs');
|
||||
|
||||
let dir: string;
|
||||
|
||||
function write(name: string, body: string) {
|
||||
writeFileSync(join(dir, name), body);
|
||||
}
|
||||
|
||||
function run() {
|
||||
const r = spawnSync(process.execPath, [script, dir], { encoding: 'utf8' });
|
||||
return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
||||
}
|
||||
|
||||
/** 飛び先になるページ。見出しの深さは 2 と 4 を混ぜてある(後述)。 */
|
||||
const TARGET = `---
|
||||
id: target
|
||||
title: 飛び先
|
||||
category: basic
|
||||
order: 1
|
||||
---
|
||||
|
||||
## 接続を切ったとき、置き場の側はどうなるか
|
||||
|
||||
本文。
|
||||
|
||||
#### 深い見出しにも id は付きます
|
||||
|
||||
本文。
|
||||
`;
|
||||
|
||||
/** 同じページの中だけで完結する本文。見出しとそこへのリンクを 1 本ずつ持つ。 */
|
||||
function selfPage(link: string) {
|
||||
return `---
|
||||
id: self
|
||||
title: 同じページ
|
||||
category: basic
|
||||
order: 3
|
||||
---
|
||||
|
||||
## 書き換わったら、押さなくても切り替わります
|
||||
|
||||
続きは [上の節](${link}) を参照。
|
||||
`;
|
||||
}
|
||||
|
||||
function source(link: string) {
|
||||
return `---
|
||||
id: source
|
||||
title: 出どころ
|
||||
category: basic
|
||||
order: 2
|
||||
---
|
||||
|
||||
案内は [こちら](${link}) にあります。
|
||||
`;
|
||||
}
|
||||
|
||||
describe('validate-help-docs.mjs の飛び先検証', () => {
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'help-docs-')); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('見出しと合っているリンクは通す', () => {
|
||||
write('01-target.md', TARGET);
|
||||
write('02-source.md', source('./01-target.md#接続を切ったとき-置き場の側はどうなるか'));
|
||||
const { code, out } = run();
|
||||
expect(out).toContain('help-docs validation OK');
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('見出しと合っていないリンクを、ファイル名・行番号・リンクの文字列つきで落とす', () => {
|
||||
write('01-target.md', TARGET);
|
||||
// 見出しの文言が変わったのにリンクが追いついていない、という壊れ方。
|
||||
write('02-source.md', source('./01-target.md#接続を切ったとき置き場の側はどうなるか'));
|
||||
const { code, out } = run();
|
||||
expect(code).toBe(1);
|
||||
expect(out).toContain('02-source.md:8:');
|
||||
expect(out).toContain('](./01-target.md#接続を切ったとき置き場の側はどうなるか)');
|
||||
expect(out).toContain("no heading '#接続を切ったとき置き場の側はどうなるか' in 01-target.md");
|
||||
});
|
||||
|
||||
it('h2/h3 以外の見出しへのリンクも通す(画面は深さによらず id を付けるため)', () => {
|
||||
write('01-target.md', TARGET);
|
||||
write('02-source.md', source('./01-target.md#深い見出しにも-id-は付きます'));
|
||||
const { code } = run();
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('同じページの中のリンクも、見出しと合っていれば通す', () => {
|
||||
write('03-self.md', selfPage('#書き換わったら-押さなくても切り替わります'));
|
||||
const { code, out } = run();
|
||||
expect(out).toContain('help-docs validation OK');
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('同じページの中のリンクも、合っていなければ名指しして落とす', () => {
|
||||
write('03-self.md', selfPage('#書き換わったら押さなくても切り替わります'));
|
||||
const { code, out } = run();
|
||||
expect(code).toBe(1);
|
||||
expect(out).toContain('03-self.md:10:');
|
||||
expect(out).toContain('](#書き換わったら押さなくても切り替わります)');
|
||||
expect(out).toContain("no heading '#書き換わったら押さなくても切り替わります' in 03-self.md");
|
||||
});
|
||||
|
||||
it('リンク先のファイルが無いときは、これまでどおり落とす', () => {
|
||||
write('01-target.md', TARGET);
|
||||
write('02-source.md', source('./99-missing.md#どこか'));
|
||||
const { code, out } = run();
|
||||
expect(code).toBe(1);
|
||||
expect(out).toContain('target file not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cp, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourceDist = join(root, 'dist');
|
||||
const sandbox = await mkdtemp(join(tmpdir(), 'maestro-worker-contract-'));
|
||||
const stagedDist = join(sandbox, 'dist');
|
||||
|
||||
try {
|
||||
await cp(sourceDist, stagedDist, { recursive: true });
|
||||
const selector = await import(pathToFileURL(join(stagedDist, 'answer-workers', 'select-answer-worker.js')).href);
|
||||
if (typeof selector.selectAnswerWorker !== 'function') {
|
||||
throw new Error('dist selector did not resolve selectAnswerWorker');
|
||||
}
|
||||
console.log('answer-worker dist runtime smoke OK');
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
# twikit: ホームタイムラインの GraphQL endpoint / FEATURES 定義の供給元
|
||||
# httpx: home timeline の raw GraphQL POST
|
||||
# PyYAML: twitter-cli 互換 YAML の出力
|
||||
twscrape>=0.19.0
|
||||
twikit>=2.3.3
|
||||
httpx>=0.27
|
||||
PyYAML>=6.0
|
||||
twscrape==0.19.2
|
||||
twikit==2.3.3
|
||||
httpx==0.28.1
|
||||
PyYAML==6.0.3
|
||||
|
||||
@@ -278,6 +278,80 @@ try:
|
||||
except ImportError:
|
||||
print("SKIP emit tests (PyYAML not installed)")
|
||||
|
||||
# ── txid indices deep scan ──
|
||||
# X が 2026-07 に未ログインページを x-web SSR 化し、indices ファイル (sign.o-*.js) が
|
||||
# entry チャンクの動的 import 先 (assets/*.js) に移った。twscrape 0.19.1 はページ直下の
|
||||
# スクリプトしか走査しないため到達できない。以下はもう 1 ホップ潜る実装のテスト。
|
||||
|
||||
ENTRY = "https://abs.twimg.com/x-web/x-web/entry-client-logged-out-B9F1BBbe.js"
|
||||
|
||||
check("chunkrefs: resolves ./assets relative to entry",
|
||||
"https://abs.twimg.com/x-web/x-web/assets/a-1234.js"
|
||||
in xa.collect_chunk_refs('import("./assets/a-1234.js")', ENTRY))
|
||||
check("chunkrefs: resolves parent-relative",
|
||||
"https://abs.twimg.com/x-web/messages/ja.js"
|
||||
in xa.collect_chunk_refs('x="../messages/ja.js"', ENTRY))
|
||||
check("chunkrefs: assets sorted ahead of non-assets",
|
||||
xa.collect_chunk_refs('"../messages/ja.js";"./assets/z-9.js"', ENTRY)[0].endswith("/assets/z-9.js"))
|
||||
check("chunkrefs: dedupes",
|
||||
len(xa.collect_chunk_refs('"./assets/a-1.js" "./assets/a-1.js"', ENTRY)) == 1)
|
||||
check("chunkrefs: ignores absolute cross-origin",
|
||||
xa.collect_chunk_refs('"https://evil.example/x.js"', ENTRY) == [])
|
||||
check("chunkrefs: caps fan-out",
|
||||
len(xa.collect_chunk_refs(" ".join(f'"./assets/c-{i}.js"' for i in range(500)), ENTRY))
|
||||
<= xa.CHUNK_SCAN_CAP)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""twscrape の HttpClient 代替。get(url).text を返すだけ。"""
|
||||
|
||||
def __init__(self, bodies):
|
||||
self.bodies = bodies
|
||||
self.fetched = []
|
||||
|
||||
async def get(self, url):
|
||||
self.fetched.append(url)
|
||||
return types.SimpleNamespace(text=self.bodies.get(url, ""))
|
||||
|
||||
|
||||
def _run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
SIGN_O = "https://abs.twimg.com/x-web/x-web/assets/sign.o-BS4wvWaM.js"
|
||||
SUB = "https://abs.twimg.com/x-web/x-web/assets/sentry-filter-CAS6J6cR.js"
|
||||
|
||||
# 1 段目に直接ある場合 (旧 = 上流と同じ挙動) は 1 ホップで返す。
|
||||
# entry と同じ階層で解決されるので assets/ は挟まらない。
|
||||
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
check("deepscan: level-1 hit",
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
== "https://abs.twimg.com/x-web/x-web/sign.o-BS4wvWaM.js")
|
||||
|
||||
# 2 段目 (今回の実際の構造): entry -> assets/sentry-filter -> sign.o
|
||||
c = _FakeClient({ENTRY: 'import("./assets/sentry-filter-CAS6J6cR.js")',
|
||||
SUB: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
check("deepscan: level-2 hit",
|
||||
_run(xa.find_indices_url_deep([ENTRY], c)) == SIGN_O)
|
||||
|
||||
# どこにも無ければ例外を上げ、txid 失敗を記録する (静かな空振りにしない)
|
||||
xa.TXID_STATE["failed"] = False
|
||||
c = _FakeClient({ENTRY: 'import("./assets/nothing-here.js")'})
|
||||
_raised = False
|
||||
try:
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
except Exception:
|
||||
_raised = True
|
||||
check("deepscan: raises when absent", _raised)
|
||||
check("deepscan: records txid failure", xa.TXID_STATE["failed"] is True)
|
||||
|
||||
# 成功時は失敗フラグをクリアする (再試行で回復したケース)
|
||||
xa.TXID_STATE["failed"] = True
|
||||
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
check("deepscan: clears flag on success", xa.TXID_STATE["failed"] is False)
|
||||
|
||||
print()
|
||||
if _fails:
|
||||
print(f"{len(_fails)} FAILED: {_fails}")
|
||||
|
||||
@@ -402,6 +402,108 @@ def _silence_logs() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── txid: indices スクリプトの深掘り探索 ──────────────────────────────
|
||||
#
|
||||
# 2026-07 に X が未ログインページを x-web の SSR 版へ寄せた。ページ直下の <script> は
|
||||
# entry チャンク 1 本だけになり、署名用 indices ファイル (sign.o-*.js) はその entry が
|
||||
# 動的 import する assets/*.js のさらに中から参照される形に変わった。
|
||||
# twscrape 0.19.1 の _find_indices_url は 1 段目しか走査しないので到達できず、
|
||||
# "Couldn't get XClientTxId indices script" で txid 生成が落ちる。txid が無いリクエストは
|
||||
# X 側が黙って 0 件を返すため、症状は「エラー無しで常に空」になる (上流 issue #320)。
|
||||
#
|
||||
# 上流 PR #321 は「cookie 付きで取れば indices を含むバンドルが来る」という案だが、
|
||||
# 実測ではこちらの認証セッションには旧 responsive-web バンドルが配信され、そこに
|
||||
# indices は含まれていなかった。よって cookie 経路には頼らず、未ログインのまま
|
||||
# もう 1 ホップ潜る。上流ロジックは残し、この関数だけ差し替える。
|
||||
|
||||
CHUNK_SCAN_CAP = 200 # 1 段目 1 チャンクあたりに追跡するサブチャンク数の上限
|
||||
CHUNK_FETCH_CONCURRENCY = 16
|
||||
|
||||
# バンドル内の相対 import (`"./assets/x-Hash.js"` / `'../messages/ja.js'`) を拾う。
|
||||
# 絶対 URL は別オリジンを踏みに行かないよう意図的に対象外。
|
||||
_CHUNK_REF_RE = re.compile(r"""["'(](\.{1,2}/[\w./-]*?\.js)["')]""")
|
||||
|
||||
# indices ファイル名。上流 twscrape の INDICES_FILE_RE と同義だが、テストを
|
||||
# twscrape 非依存で回すため自前に持つ (上流のリネームからも切り離される)。
|
||||
# \b は design.o-*.js のような部分一致除け。
|
||||
INDICES_FILE_RE = re.compile(r"(?:\.{0,2}/)?[\w./-]*?\b(?:ondemand\.s|sign\.o)[\w.-]*\.js")
|
||||
|
||||
TXID_STATE = {"failed": False}
|
||||
|
||||
|
||||
def collect_chunk_refs(body: str, base_url: str) -> list[str]:
|
||||
"""チャンク本文から、次に辿るべきサブチャンクの絶対 URL 一覧を作る。
|
||||
|
||||
indices は assets/ 配下にあるので assets/ を含む参照を先に試す (i18n の
|
||||
../messages/*.js が数百件あり、後回しにしないと無駄打ちが増える)。
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
seen, assets, others = set(), [], []
|
||||
for m in _CHUNK_REF_RE.finditer(body):
|
||||
url = urljoin(base_url, m.group(1))
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
(assets if "/assets/" in url else others).append(url)
|
||||
return (assets + others)[:CHUNK_SCAN_CAP]
|
||||
|
||||
|
||||
async def find_indices_url_deep(scripts: list, clt) -> str:
|
||||
"""indices ファイル (sign.o-*.js / ondemand.s*.js) の URL を解決する。
|
||||
|
||||
1 段目 = 上流と同じくページ直下のスクリプト本文を走査。見つからなければ
|
||||
2 段目 = 各スクリプトが import している相対チャンクを走査する。
|
||||
どこにも無ければ TXID_STATE に記録した上で例外を上げる (握り潰さない)。
|
||||
"""
|
||||
import asyncio
|
||||
from urllib.parse import urljoin
|
||||
|
||||
sem = asyncio.Semaphore(CHUNK_FETCH_CONCURRENCY)
|
||||
|
||||
async def fetch(url: str) -> tuple[str, str]:
|
||||
async with sem:
|
||||
try:
|
||||
return url, (await clt.get(url)).text
|
||||
except Exception:
|
||||
return url, ""
|
||||
|
||||
async def scan(urls: list) -> tuple[str, list]:
|
||||
"""走査してヒット URL を返す。未ヒットなら次段の候補も併せて返す。"""
|
||||
nxt = []
|
||||
tasks = [asyncio.ensure_future(fetch(u)) for u in urls]
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
url, body = await fut
|
||||
m = INDICES_FILE_RE.search(body)
|
||||
if m:
|
||||
return urljoin(url, m.group(0)), []
|
||||
nxt.extend(collect_chunk_refs(body, url))
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
return "", nxt
|
||||
|
||||
hit, level2 = await scan(list(scripts))
|
||||
if not hit and level2:
|
||||
hit, _ = await scan(list(dict.fromkeys(level2)))
|
||||
|
||||
if not hit:
|
||||
TXID_STATE["failed"] = True
|
||||
raise Exception("Couldn't get XClientTxId indices script")
|
||||
|
||||
TXID_STATE["failed"] = False
|
||||
return hit
|
||||
|
||||
|
||||
def _install_xclid_deep_scan() -> None:
|
||||
"""twscrape の indices 探索を深掘り版へ差し替える (import 済みが前提)。"""
|
||||
try:
|
||||
import twscrape.xclid as _xclid
|
||||
_xclid._find_indices_url = find_indices_url_deep
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── backend (twscrape / twikit、遅延 import) ──────────────────────────
|
||||
|
||||
def _creds() -> tuple[str, str]:
|
||||
@@ -441,12 +543,19 @@ def _cleanup_stale_tmp() -> None:
|
||||
|
||||
|
||||
async def _ensure_alive(api) -> None:
|
||||
"""直近のリクエストでアカウントが 401/banned 扱いになっていないか確認。
|
||||
"""空結果が『本当に0件』か『取得できていない』かを切り分け、後者は nonzero で落とす。
|
||||
|
||||
twscrape は 401/429 を例外にせずログして停止し、空イテレーションを返す。空結果が
|
||||
『本当に0件』か『認証切れ/レート制限』かを区別するため、停止時に無効化される
|
||||
account.active を見て、無効なら nonzero で fail する (x.ts がエラー表示)。
|
||||
twscrape は 401/429 も txid 生成失敗も例外にせずログして停止し、空イテレーションを
|
||||
返す。黙って空を返すと利用者からは「エラーも出ないのに何も取れない」に見えるので、
|
||||
原因が分かる形で fail させる (x.ts がそのまま表示する)。
|
||||
- txid 生成失敗 … X のバンドル構成変更。TXID_STATE で検知
|
||||
- 認証切れ/BAN/レート制限 … 停止時に account.active が落ちる
|
||||
"""
|
||||
if TXID_STATE.get("failed"):
|
||||
fail("x-adapter: could not build the X request signature (x-client-transaction-id); "
|
||||
"X changed its web bundle layout again. Every request returns empty until the "
|
||||
"indices lookup is updated. See scripts/x-adapter/x_adapter.py "
|
||||
"(find_indices_url_deep) and twscrape upstream issue #320.")
|
||||
try:
|
||||
accs = await api.pool.get_all()
|
||||
except Exception:
|
||||
@@ -463,6 +572,7 @@ async def _make_api():
|
||||
import tempfile
|
||||
from twscrape import API
|
||||
_silence_logs() # twscrape import 後に呼ぶ (import 時に loguru sink が張られるため)
|
||||
_install_xclid_deep_scan()
|
||||
auth, ct0 = _creds()
|
||||
tmpdir = tempfile.mkdtemp(prefix=_TMP_PREFIX)
|
||||
db = os.path.join(tmpdir, "accounts.db")
|
||||
@@ -601,6 +711,7 @@ async def cmd_feed(args) -> None:
|
||||
from twikit.client.gql import Endpoint
|
||||
from twikit.constants import FEATURES
|
||||
_silence_logs() # twscrape import 後に呼ぶ
|
||||
_install_xclid_deep_scan() # feed は _make_api を通らないのでここでも要適用
|
||||
|
||||
auth, ct0 = _creds()
|
||||
following = (args.tab or "").strip().lower() == "following"
|
||||
|
||||
Reference in New Issue
Block a user