Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit e393cbccab6752b7ee6b837ae0f3d7ccd9b216fb
parent ad1dd112f7360625366e6fe3586d1583bb426a02
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 19:07:13 -0400

editor(e2e): ops-cut-release.spec.ts — the ops door over the fixture changelogs; the test server's EXPORT_CHANGELOG_FILE points at a gitignored copy

Three tests: an editor cut answers 200 with the heading (the form's page then
has nothing pending, and a second cut is the writer's 400); `all` cuts both
with one version (next-minor against the higher latest) and a partial `all`
400 still reports the editor it cut; every bad workspace / version / date /
commit / key is a 400 and a missing token a 401, with the file untouched.
dev:test/start:test now set EXPORT_CHANGELOG_FILE=test-export-changelog.md
(gitignored), so no spec can reach the tracked export/CHANGELOG.md through
the new route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
M.gitignore | 6++++--
Mcommon/lib/paths.ts | 4+++-
Aeditor/e2e/ops-cut-release.spec.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/package.json | 4++--
4 files changed, 184 insertions(+), 5 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -98,9 +98,11 @@ yarn-error.log* # editor e2e fixtures and ephemeral state /editor/test-transcripts/ /editor/test-settings.json -# Disposable changelog for the cut-release e2e (EDITOR_CHANGELOG_FILE in -# dev:test/start:test). Keeps that spec off the repo's tracked CHANGELOG.md. +# Disposable changelogs for the cut-release e2e (EDITOR_CHANGELOG_FILE and +# EXPORT_CHANGELOG_FILE in dev:test/start:test). Keep those specs — and +# POST /api/ops/cut-release, which can cut both — off the tracked CHANGELOG.md files. /editor/test-changelog.md +/editor/test-export-changelog.md /editor/playwright-report/ /editor/test-results/ /editor/blob-report/ diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -55,7 +55,9 @@ export type Paths = { // are TRACKED source files, and cutting a release optionally makes a real git // commit — so e2e must be able to point them somewhere disposable. Overridable // via EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE; the editor's dev:test and - // start:test scripts set the first to editor/test-changelog.md (gitignored). + // start:test scripts set them to editor/test-changelog.md and + // editor/test-export-changelog.md (both gitignored) — the second since + // POST /api/ops/cut-release made the export changelog reachable from a spec. // Without this the changelog spec rewrites the repo's own changelog and can // leave a stray "Release editor <version>" commit behind. editorChangelogFile: string; diff --git a/editor/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts @@ -0,0 +1,175 @@ +// POST /api/ops/cut-release — `pnpm ops cut-release` (release 10 slice P). +// +// The route is the third caller of the one release writer +// (common/controller/cutRelease.ts), after the Cut release form +// (cut-release.spec.ts) and `archilyzer release cut` (unit-tested). What is +// pinned here is the HTTP door: the token, the body's allow-list, the 400s that +// refuse a bad workspace / version / date before anything is written, and a +// cut that answers with the heading it wrote. +// +// BOTH CHANGELOGS ARE THE FIXTURE'S. The test server's EDITOR_CHANGELOG_FILE +// and EXPORT_CHANGELOG_FILE (editor/package.json, dev:test/start:test) point at +// gitignored copies beside this directory, so `all` rewrites neither tracked +// CHANGELOG.md. Nothing here passes "commit": a commit of an ignored file is +// not a thing git does, and the commit path is covered by the controller's own +// tests, each in a git repo of its own. + +import { readFile, writeFile } from "node:fs/promises"; +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; +import { resolvePath } from "./helpers"; + +const EDITOR_CHANGELOG = resolvePath("test-changelog.md"); +const EXPORT_CHANGELOG = resolvePath("test-export-changelog.md"); +const AUTH = { authorization: "Bearer test-worker-token" }; + +type CutResponse = { + ok?: boolean; + error?: string; + version?: string | null; + results?: Record<string, unknown>[]; + notAttempted?: string[]; +}; + +function changelog(latest: string, pending: string[] = []): string { + return [ + "# Changelog", + "", + ...(pending.length ? ["## [Unreleased]", ...pending, ""] : []), + `## [${latest}] - 2024-01-01`, + "- old released bullet", + "", + ].join("\n"); +} + +// cut-release.spec.ts leaves the editor copy in this state, and /changelog and +// /sites read these files unconditionally. +const RELEASED_EDITOR = changelog("9.9.9"); +const RELEASED_EXPORT = changelog("1.0.0"); + +function todayISO(): string { + const now = new Date(); + const y = now.getFullYear().toString().padStart(4, "0"); + const m = (now.getMonth() + 1).toString().padStart(2, "0"); + const d = now.getDate().toString().padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +async function cut( + request: APIRequestContext, + data: Record<string, unknown>, + headers: Record<string, string> = AUTH, +): Promise<{ status: number; body: CutResponse }> { + const res = await request.post(`${baseUrl}/api/ops/cut-release`, { headers, data }); + return { status: res.status(), body: (await res.json()) as CutResponse }; +} + +test.afterEach(async () => { + await writeFile(EDITOR_CHANGELOG, RELEASED_EDITOR); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); +}); + +test("cut-release cuts the editor changelog and answers with the heading it wrote", async ({ + request, +}) => { + await writeFile(EDITOR_CHANGELOG, changelog("9.9.9", ["- a bullet cut over HTTP"])); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); + + const first = await cut(request, { workspace: "editor", version: "next" }); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body).toEqual({ + ok: true, + version: "9.9.10", + results: [ + { + ok: true, + workspace: "editor", + version: "9.9.10", + heading: `## [9.9.10] - ${todayISO()}`, + committed: false, + }, + ], + }); + const after = await readFile(EDITOR_CHANGELOG, "utf8"); + expect(after).toContain(`## [9.9.10] - ${todayISO()}\n- a bullet cut over HTTP`); + expect(after).not.toMatch(/^##\s+\[Unreleased\]/im); + // The export changelog was not named, so it is not touched. + expect(await readFile(EXPORT_CHANGELOG, "utf8")).toBe(RELEASED_EXPORT); + + // The form reads the same file: it now has nothing to cut. + const page = await request.get(`${baseUrl}/changelog`); + expect(await page.text()).toContain("Nothing pending under [Unreleased] yet."); + + // Cutting again is the writer's own refusal, as a 400. + const again = await cut(request, { workspace: "editor", version: "next" }); + expect(again.status).toBe(400); + expect(again.body.ok).toBe(false); + expect(again.body.error).toBe("Nothing pending to release under [Unreleased]."); +}); + +test("all cuts both changelogs with ONE version, resolved against the higher latest", async ({ + request, +}) => { + await writeFile(EDITOR_CHANGELOG, changelog("9.9.9", ["- editor bullet"])); + await writeFile(EXPORT_CHANGELOG, changelog("10.0.0", ["- export bullet"])); + + const res = await cut(request, { + workspace: "all", + version: "next-minor", + date: "2026-09-26", + }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.version).toBe("10.1.0"); + expect(res.body.results?.map((r) => [r.workspace, r.heading])).toEqual([ + ["editor", "## [10.1.0] - 2026-09-26"], + ["export", "## [10.1.0] - 2026-09-26"], + ]); + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toContain( + "## [10.1.0] - 2026-09-26\n- editor bullet", + ); + expect(await readFile(EXPORT_CHANGELOG, "utf8")).toContain( + "## [10.1.0] - 2026-09-26\n- export bullet", + ); + + // With only the editor pending, `all` cuts the editor, refuses the export + // (no [Unreleased] heading at all), and the 400 still carries what was done. + await writeFile(EDITOR_CHANGELOG, changelog("10.1.0", ["- later"])); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); + const partial = await cut(request, { workspace: "all", version: "10.1.1" }); + expect(partial.status).toBe(400); + expect(partial.body.error).toBe( + "export: Could not find a `## [Unreleased]` heading to cut from.", + ); + expect(partial.body.results?.map((r) => [r.workspace, r.ok])).toEqual([ + ["editor", true], + ["export", false], + ]); + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toContain(`## [10.1.1] - ${todayISO()}`); +}); + +test("a bad workspace, version, date or key is a 400 before anything is written", async ({ + request, +}) => { + const pending = changelog("9.9.9", ["- must survive every refusal"]); + await writeFile(EDITOR_CHANGELOG, pending); + + const cases: [Record<string, unknown>, RegExp][] = [ + [{ workspace: "site", version: "1.0.0" }, /"workspace" must be one of editor, export, all/], + [{ version: "next" }, /"workspace" is required/], + [{ workspace: "editor" }, /"version" is required/], + [{ workspace: "editor", version: "v1" }, /"v1" is not a valid semver/], + [{ workspace: "editor", version: "next", date: "26/09/2026" }, /not in YYYY-MM-DD form/], + [{ workspace: "editor", version: "next", commit: "yes" }, /"commit" must be a boolean/], + [{ workspace: "editor", version: "next", push: true }, /unknown key\(s\): push/], + ]; + for (const [data, error] of cases) { + const res = await cut(request, data); + expect(res.status, JSON.stringify(data)).toBe(400); + expect(res.body.error, JSON.stringify(data)).toMatch(error); + } + // The token gate is the ops door's. + const anonymous = await cut(request, { workspace: "editor", version: "next" }, {}); + expect(anonymous.status).toBe(401); + + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toBe(pending); +}); diff --git a/editor/package.json b/editor/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "next dev --port ${EDITOR_PORT:-3001}", - "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", - "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", + "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", + "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", "build": "next build", "start": "next start --port ${EDITOR_PORT:-3001}", "lint": "eslint",