Archilyzer · Source

archilyzer

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

commit 1275ccbf877d55b6eb216c3cb971640b5eb2d88d
parent a335cdb3f1f210e376af5c03224a77ed55e494e1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 12:38:41 -0400

e2e: the publish stages' seams — EXPORT_NEXT_BIN (a fake next build), the fake wrangler's mode sidecar, ARCHILYZER_BRANCH=main; specs for the moved labels

- common: `nextBuildStep` runs `<EXPORT_NEXT_BIN> build` in place of `pnpm
  exec next build` for a site's and the hub's build (ENVIRONMENT.md
  regenerated). The editor's test server points it at
  e2e/fixtures/bin/fake-next.mjs, which copies the composed public dir to
  export/out: compose runs for real, the export app is never rebuilt beside
  its dev server.
- playwright.config.ts: ARCHILYZER_BRANCH=main (a worktree's branch is never
  main, and production refuses any other build), a dummy CLOUDFLARE_API_TOKEN
  for the credential preflight (the fake never sends it), EXPORT_NEXT_BIN.
- fake-wrangler.mjs reads `<exportBuildsDir>/.fake-wrangler-mode.json`
  {"authFail": true}, so one spec can make a deploy fail as Cloudflare
  refusing the token; E2E_FAKE_WRANGLER_AUTH_FAIL stays the fallback.
- publishRunStream: a job of a run that ends cancelled cancels the jobs after
  it (the console's Cancel is the run's).
- build.spec (no "Skip data rebuild"), deploy-page.spec (the Publish panel's
  rows replace "Build all sites" and the batch panel), sites-homepage.spec
  (the Homepage row; publish-* kinds on the publish queue; the chips say
  "no index yet").
- source.test: the "without a valid manifest" refusal keeps a test (its only
  one was build.test's deployHomepage's); two stale comments in
  builtExport.test.

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

Diffstat:
MENVIRONMENT.md | 1+
Mcommon/lib/builtExport.test.ts | 10++++------
Mcommon/lib/envVars.ts | 1+
Mcommon/publish/build.test.ts | 14++++++++++++++
Mcommon/publish/build.ts | 22+++++++++++++++-------
Mcommon/publish/source.test.ts | 15+++++++++++++++
Meditor/app/sites/lib/publishRunStream.ts | 9+++++++++
Meditor/e2e/build.spec.ts | 12+++++++-----
Meditor/e2e/deploy-page.spec.ts | 89++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Aeditor/e2e/fixtures/bin/fake-next.mjs | 45+++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/fixtures/bin/fake-wrangler.mjs | 36++++++++++++++++++++++++++++++------
Meditor/e2e/sites-homepage.spec.ts | 121+++++++++++++++++++++++++++++++++++++------------------------------------------
Meditor/playwright.config.ts | 23++++++++++++++++++++++-
13 files changed, 267 insertions(+), 131 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -73,6 +73,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not | `ARCHIVE_CHANNEL_CONCURRENCY` | `4` | How many channels' archive zips `build archives` builds at once. | common/bin/build-archives.ts | | `HOST` | every interface | The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine. | export/scripts/serve-out.mjs | | `MAX_ARCHIVE_BYTES` | the Cloudflare-safe cap | The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins. | common/bin/compose-site.ts | +| `EXPORT_NEXT_BIN` | unset: `pnpm exec next build` in export/ | The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out. | common/publish/build.ts (nextBuildStep) | | `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts | | `CHOUGH_BIN` | `chough` on PATH | The chough transcription engine, when a worker names no binary. | common/lib/transcriptionApps.ts | | `CHOUGH_MODEL` | chough's own | Passed to chough from a worker's model field; chough auto-downloads one when unset. | chough (set by common/lib/transcriptionApps.ts) | diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -228,9 +228,8 @@ test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, mome }); // The homepage package builds into its own homepage/out, which nothing else -// writes, so "built" is one question: is there an index.html? The deploy-only -// action refuses before any job on exactly the file deployHomepage checks -// inside it. +// writes, so "built" is one question: is there an index.html? The homepage's +// deploy stage (deployStage.ts) refuses on exactly that file. test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => { const built = tempOut(); const empty = tempOut(); @@ -317,9 +316,8 @@ test("builtBundleProblem refuses a bundle missing either identity file, naming t } }); -// deploySite and the editor's deploy action answer with builtSiteProblem before -// any job; the deploy itself refuses with builtBundleProblem just before -// wrangler. The two must never disagree about a bundle: a legitimately built +// builtSiteProblem is the looser question; the deploy stage refuses with +// builtBundleProblem just before wrangler. The two must never disagree about a bundle: a legitimately built // site refused only at the last step, or a bad one let through to it. test("builtSiteProblem refuses exactly what builtBundleProblem refuses", () => { const cases: { name: string; site?: unknown; corpus?: unknown }[] = [ diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -109,6 +109,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "ARCHIVE_CHANNEL_CONCURRENCY", audience: "runtime", default: "`4`", readBy: "common/bin/build-archives.ts", doc: "How many channels' archive zips `build archives` builds at once." }, { name: "HOST", audience: "runtime", default: "every interface", readBy: "export/scripts/serve-out.mjs", doc: "The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine." }, { name: "MAX_ARCHIVE_BYTES", audience: "runtime", default: "the Cloudflare-safe cap", readBy: "common/bin/compose-site.ts", doc: "The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins." }, + { name: "EXPORT_NEXT_BIN", audience: "runtime", default: "unset: `pnpm exec next build` in export/", readBy: "common/publish/build.ts (nextBuildStep)", doc: "The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out." }, { name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." }, { name: "CHOUGH_BIN", audience: "runtime", default: "`chough` on PATH", readBy: "common/lib/transcriptionApps.ts", doc: "The chough transcription engine, when a worker names no binary." }, { name: "CHOUGH_MODEL", audience: "runtime", default: "chough's own", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's model field; chough auto-downloads one when unset." }, diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -116,6 +116,20 @@ test("buildSiteSteps: allowMissingMedia lets compose through a report citation w ); }); +test("EXPORT_NEXT_BIN replaces `pnpm exec next build` in a site's and the hub's build (the e2e fake)", () => { + const p = { exportDir: "/x/export", transcriptsDir: "/x/t", exportPublicDir: "/x/t/.p" } as Paths; + const site = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } }); + assert.deepEqual(site.map((s) => [s.command, ...s.args]), [ + ["pnpm", "run", "compose:site"], + ["/bin/fake-next", "build"], + ]); + const hub = buildHubSteps({ paths: p, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } }); + assert.deepEqual(hub[1].command, "/bin/fake-next"); + assert.equal(hub[1].env?.INSTANCE_MODE, "hub"); + const plain = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: {} }); + assert.deepEqual(plain[1].args, ["exec", "next", "build"]); +}); + test("buildHubSteps: compose:hub, then next build with INSTANCE_MODE=hub, in export/", () => { const steps = buildHubSteps({ paths, baseEnv: { PATH: "/bin" } }); const env = { diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -99,10 +99,23 @@ export function buildSiteSteps(opts: { return [ ...(opts.skipData ? [] : [step(["run", "build:data"])]), step(["run", "compose:site"]), - step(["exec", "next", "build"]), + nextBuildStep(paths, env), ]; } +/** + * The export app's `next build`: `pnpm exec next build` in export/, or + * `<EXPORT_NEXT_BIN> build` when that is set — the editor's e2e suite points + * it at a fake that copies the composed public dir to out/ (a real build there + * would rebuild the app beside its running dev server). + */ +export function nextBuildStep(paths: Paths, env: NodeJS.ProcessEnv): BuildStep { + const bin = env.EXPORT_NEXT_BIN?.trim(); + return bin + ? { command: bin, args: ["build"], cwd: paths.exportDir, env } + : { command: "pnpm", args: ["exec", "next", "build"], cwd: paths.exportDir, env }; +} + // Run a list of child steps in order, streaming into `onLog`, stopping at the // first non-zero exit (or a cancel). Returns the exit code. async function runSteps( @@ -582,12 +595,7 @@ export function buildHubSteps(opts: { }; return [ { command: "pnpm", args: ["run", "compose:hub"], cwd: paths.exportDir, env }, - { - command: "pnpm", - args: ["exec", "next", "build"], - cwd: paths.exportDir, - env: { ...env, INSTANCE_MODE: "hub" }, - }, + nextBuildStep(paths, { ...env, INSTANCE_MODE: "hub" }), ]; } diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts @@ -976,3 +976,18 @@ test("history: a refusal with a render cache it cannot remove still exits 1 and chmodSync(cacheDir, 0o755); } }); + +test("a homepage/out holding a source publish with no manifest is refused (the deploy check; its old home was build.test.ts deployHomepage)", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "source-no-manifest-")); + try { + const out = path.join(root, "homepage", "out"); + mkdirSync(path.join(out, "source", MIRROR_DIR), { recursive: true }); + writeFileSync(path.join(out, "source", "index.html"), "<p>the /source page</p>"); + assert.match( + (await publishedSourceProblem({ monorepoRoot: root } as Paths, out))!, + /homepage\/out holds a source publish without a valid manifest/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/editor/app/sites/lib/publishRunStream.ts b/editor/app/sites/lib/publishRunStream.ts @@ -5,6 +5,11 @@ // line, and settles when the last one does: the run's verdict is its first // job that did not end `done`, else `done`. // +// A CANCEL IS THE RUN'S. The console's Cancel cancels the run's first job (the +// id it holds); a job of the run that ends `cancelled` cancels every job +// after it, queued or running — else the console would wait on a build still +// queued behind something else, for a run its operator called off. +// // Nothing here chains one job to another — the order is on disk (a build // carries `indexAfter`, a deploy `builtAfter`; publishStages.ts). A deploy // whose build failed ends `failed` itself, with the sentence in its log. @@ -12,6 +17,7 @@ // Not a "use server" module: a server action file may only export async // functions, and these are plain helpers the actions call. +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import type { JobDoneResult, StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; export type RunPart = { @@ -64,6 +70,9 @@ export function followRun( } const term = part.done ? await part.done : null; if (term && term.status !== "done" && verdict === null) verdict = term; + if (term?.status === "cancelled") { + for (const later of parts.slice(i + 1)) getRegistry().cancel(later.jobId); + } } resolveDone(verdict ?? { status: "done", jobId: first.jobId }); if (!closed) { diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts @@ -90,9 +90,11 @@ test("build static export lives on the site's Publish tab, not the family page", }); await expect(exportButton).toBeEnabled(); - // The build-only control offers a "skip data rebuild" option (compose from - // existing staging without regenerating index/stats/charts). Default off. - const skipData = page.getByLabel("Skip data rebuild (index, stats, charts)"); - await expect(skipData).toBeVisible(); - await expect(skipData).not.toBeChecked(); + // No "skip data rebuild" any more (release 18): the data is the index + // stage, shared by every site, and a build updates it first when it is + // stale. The archive opt-out stays, default off. + await expect(page.getByLabel("Skip data rebuild (index, stats, charts)")).toHaveCount(0); + const skipArchives = page.getByLabel("Skip archive zips (transcript & live-chat downloads)"); + await expect(skipArchives).toBeVisible(); + await expect(skipArchives).not.toBeChecked(); }); diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts @@ -2,30 +2,35 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; import { resetData, writeSite } from "./helpers"; -// The "pick specific sites" panel (BuildSitesPanel). /sites renders two -// controls named "Deploy after build" — one here, one in BuildAllSitesButton — -// so anything targeting this panel's copy must be scoped to it by name. -const batchPanel = (page: Page) => - page.getByRole("group", { name: "Build specific sites" }); +// Where publishing lives (release 18): the family page has the Publish panel — +// a row per site, the hub and the homepage, Publish now and Build all stale — +// and a site's Publish tab has its own Build & deploy. The panel's stages run +// in publish.spec; this pins the layout and the wiring decided before a job. + +const row = (page: Page, siteId: string) => + page.getByRole("group", { name: `Publish ${siteId}` }); test.beforeEach(async () => { await resetData("empty"); }); -test("publishing splits: the family page releases and batches, the site page builds & deploys", async ({ +test("publishing splits: the family page releases and publishes, the site page builds & deploys", async ({ page, }) => { await writeSite("testsite", { cloudflareProject: "proj" }); await page.goto("/sites"); const release = page.getByRole("heading", { name: "Release notes" }); - const batch = page.getByRole("heading", { name: "Build all sites" }); + const publish = page.getByRole("heading", { name: "Publish", exact: true }); await expect(release).toBeVisible(); - await expect(batch).toBeVisible(); - // Order: the release sits above the batch panel. + await expect(publish).toBeVisible(); + // Order: the release sits above the Publish panel. expect((await release.boundingBox())!.y).toBeLessThan( - (await batch.boundingBox())!.y, + (await publish.boundingBox())!.y, ); + // "Build all sites" and the batch panel are gone. + await expect(page.getByRole("heading", { name: "Build all sites" })).toHaveCount(0); + await expect(page.getByRole("group", { name: "Build specific sites" })).toHaveCount(0); await expect( page.getByRole("heading", { name: "Build & deploy", exact: true }), ).toHaveCount(0); @@ -35,7 +40,7 @@ test("publishing splits: the family page releases and batches, the site page bui page.getByRole("heading", { name: "Build & deploy", exact: true }), ).toBeVisible(); await expect( - page.getByRole("heading", { name: "Build all sites" }), + page.getByRole("heading", { name: "Publish", exact: true }), ).toHaveCount(0); await expect( page.getByRole("heading", { name: "Release notes" }), @@ -51,16 +56,12 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje // With a Cloudflare project → enabled. await page.goto("/sites/with-proj/publish"); await expect( - // exact: true — kept from the /deploy days, where "Build & deploy all - // sites" sat below; harmless here. page.getByRole("button", { name: "Build & deploy", exact: true }), ).toBeEnabled(); // Without one → disabled, with an explanatory notice. await page.goto("/sites/no-proj/publish"); await expect( - // exact: true — kept from the /deploy days, where "Build & deploy all - // sites" sat below; harmless here. page.getByRole("button", { name: "Build & deploy", exact: true }), ).toBeDisabled(); await expect( @@ -68,46 +69,50 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje ).toBeVisible(); }); -// The Basic | Docker toggle was a label nothing read, and it is gone (release -// 11, follow-up O6c): Build all uses containers whenever an engine answers. -test("/sites has no build-mode toggle, and Build all says when it uses containers", async ({ +test("/sites has no build-mode toggle: the Publish panel has Publish now and Build all stale", async ({ page, }) => { await writeSite("testsite", { cloudflareProject: "proj" }); await page.goto("/sites"); - await expect(page.getByRole("heading", { name: "Build all sites" })).toBeVisible(); await expect(page.getByRole("group", { name: "Build mode" })).toHaveCount(0); - await expect( - page.getByText(/When a container engine answers: the shared data phase runs once/i), - ).toBeVisible(); + await expect(page.getByRole("button", { name: "Publish now", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Build all stale", exact: true })).toBeVisible(); + // The plan Publish now would run is shown before it is pressed: with no + // index stamp yet, the index update comes first. + await expect(page.getByTestId("publish-plan")).toContainText("update-index _index"); }); -test("batch panel: selecting sites enables the launch button and reflects deploy intent", async ({ +test("a row per site: its chips, and the deploy buttons only where it can deploy", async ({ page, }) => { - // Deliberately does NOT click the launch button — that would spawn a real - // `pnpm run build`, which the e2e suite never does (cf. build.spec.ts only - // runs the lightweight Build index job). We verify the panel's UI wiring. await writeSite("alpha", { siteTitle: "Alpha", cloudflareProject: "a" }); - await writeSite("beta", { siteTitle: "Beta", cloudflareProject: "b" }); + await writeSite("beta", { siteTitle: "Beta" }); + await writeSite("mine", { siteTitle: "Mine", audience: "private" }); await page.goto("/sites"); - const launch = page.getByRole("button", { name: "Build selected" }); - await expect(launch).toBeDisabled(); + const alpha = row(page, "alpha"); + await expect(alpha.getByRole("button", { name: "Build", exact: true })).toBeVisible(); + await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeVisible(); + await expect(alpha.getByRole("button", { name: "Deploy production", exact: true })).toBeVisible(); + // No index yet: every row says so on its index chip, and its build waits for it. + await expect(alpha.locator('[data-chip="index"]')).toContainText("no index yet"); + await expect(alpha.locator('[data-chip="built"]')).toContainText("update the index first"); + await expect(alpha.locator('[data-chip="deployed"]')).toBeVisible(); + await expect(alpha.locator('[data-chip="live"]')).toBeVisible(); - await page.getByLabel("Select Alpha").check(); - await page.getByLabel("Select Beta").check(); - await expect(launch).toBeEnabled(); - await expect(page.getByText("2 selected")).toBeVisible(); + // No project: built, never deployed — and it says why. + const beta = row(page, "beta"); + await expect(beta.getByRole("button", { name: "Build", exact: true })).toBeVisible(); + await expect(beta.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0); + await expect(beta.getByText(/No Cloudflare Pages project/)).toBeVisible(); - // Opting into deploy renames the launch button. Scoped to the batch panel: - // BuildAllSitesButton has an identically-labelled checkbox in the same - // section, so an unscoped getByLabel resolves to two elements. - await batchPanel(page).getByLabel("Deploy after build").check(); - await expect( - page.getByRole("button", { name: "Build & deploy selected" }), - ).toBeVisible(); + // Private: never deployed. + const mine = row(page, "mine"); + await expect(mine.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0); + await expect(mine.getByText(/is private \(audience: private\)/)).toBeVisible(); - // The panel notes that the batch runs serially, whatever the mode. - await expect(page.getByText(/These run one at a time/i)).toBeVisible(); + // A preview name is judged as you type, with the server's sentence. + await alpha.getByRole("textbox", { name: "preview branch", exact: true }).fill("main"); + await expect(alpha.getByRole("status", { name: "preview problem" })).toContainText(/production branch/); + await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeDisabled(); }); diff --git a/editor/e2e/fixtures/bin/fake-next.mjs b/editor/e2e/fixtures/bin/fake-next.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// E2E fake `next` for the export app's build (release 18 S4). A site's and the +// hub's build stage run compose for real, then `<EXPORT_NEXT_BIN> build` in +// export/ in place of `pnpm exec next build` (common/publish/build.ts) — which +// in the editor's e2e would rebuild the export app beside its running dev +// server, for a minute, to prove nothing about publishing. +// +// What a real `next build` of a static export leaves that the publish stages +// read is `export/out`: the composed `public/` copied in (site.json and +// corpus.json name the site — builtBundleProblem; the hub's tombstones — +// builtHubProblem) and the rendered pages. This writes exactly that: the +// composed public dir (EXPORT_PUBLIC_DIR, which the build step sets) copied to +// ./out, and an index.html. Nothing is rendered. +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { installFixtureWatchdog } from "./_watchdog.mjs"; + +installFixtureWatchdog(); + +const argv = process.argv.slice(2); +if (argv[0] === "--version" || argv[0] === "-v") { + process.stdout.write("Next.js v16.0.0 (fake)\n"); + process.exit(0); +} +if (argv[0] !== "build") { + process.stderr.write(`[fake-next] unsupported invocation: ${argv.join(" ")}\n`); + process.exit(2); +} + +const publicDir = process.env.EXPORT_PUBLIC_DIR?.trim() || path.resolve("public"); +if (!existsSync(publicDir)) { + process.stderr.write(`[fake-next] no public dir at ${publicDir}\n`); + process.exit(1); +} +const out = path.resolve("out"); +rmSync(out, { recursive: true, force: true }); +mkdirSync(out, { recursive: true }); +cpSync(publicDir, out, { recursive: true, dereference: true }); +const mode = process.env.INSTANCE_MODE === "hub" ? "hub" : "site"; +writeFileSync( + path.join(out, "index.html"), + `<!doctype html><html><head><title>fake ${mode} build</title></head><body>${mode}</body></html>\n`, +); +process.stdout.write(" ▲ Next.js 16 (fake)\n"); +process.stdout.write(` ✓ Exported ${mode} from ${publicDir} to ${out}\n`); diff --git a/editor/e2e/fixtures/bin/fake-wrangler.mjs b/editor/e2e/fixtures/bin/fake-wrangler.mjs @@ -13,11 +13,15 @@ // what was "deployed", from where, to which branch; // - prints wrangler 4's success lines, ending on "Take a peek over at // https://<branch>.<project>.pages.dev" — the line deploymentUrlIn reads; -// - with E2E_FAKE_WRANGLER_AUTH_FAIL=1, prints wrangler's own refusal for a -// token Cloudflare does not accept ("Authentication error [code: 10000]") -// and exits 1, without writing the sidecar — the deploy stage must turn -// that into "[deploy] REFUSED by Cloudflare — the API token was not -// accepted" and leave deployed.json untouched; +// - in AUTH-FAIL mode, prints wrangler's own refusal for a token Cloudflare +// does not accept ("Authentication error [code: 10000]") and exits 1, +// without writing the sidecar — the deploy stage must turn that into +// "[deploy] REFUSED by Cloudflare — the API token was not accepted" and +// leave deployed.json untouched. The mode is a MODE SIDECAR a spec writes +// and removes, `<exportBuildsDir>/.fake-wrangler-mode.json` `{"authFail": +// true}` (the editor's actions pass the stage no env of a spec's, so an +// env var cannot reach one test; fake-ytdlp.mjs's sidecar pattern), or +// E2E_FAKE_WRANGLER_AUTH_FAIL=1 as the fallback; // - answers `--version` like the pinned binary. // // A missing <outDir> fails as wrangler does. Anything else is a usage error. @@ -51,7 +55,27 @@ const branch = opt("--branch") ?? "main"; process.stdout.write(`\n ⛅️ wrangler ${VERSION} (fake)\n───────────────────\n`); -if (process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") { +// The mode sidecar: in EXPORT_BUILDS_DIR when set, else beside the editor's +// export-public dir (paths.ts exportBuildsDir), else two levels above the +// bundle (<builds>/<target>/out). +function modeSidecar() { + const dirs = [ + process.env.EXPORT_BUILDS_DIR, + process.env.EXPORT_PUBLIC_DIR && path.join(path.dirname(process.env.EXPORT_PUBLIC_DIR), ".export-builds"), + path.dirname(path.dirname(outDir)), + ].filter(Boolean); + for (const d of dirs) { + try { + return JSON.parse(readFileSync(path.join(d, ".fake-wrangler-mode.json"), "utf8")); + } catch { + // absent or unreadable: the next place + } + } + return {}; +} +const mode = modeSidecar(); + +if (mode.authFail === true || process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") { process.stderr.write( `\n✘ [ERROR] A request to the Cloudflare API (/accounts/0000/pages/projects/${project ?? "?"}) failed.\n\n` + " Authentication error [code: 10000]\n\n" + diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts @@ -1,22 +1,22 @@ -// /sites → Homepage: the `homepage` package's build and deploy (release 11, -// slice O4), the twin of the Hub section's buttons. +// /sites → Publish → the Homepage row: the `homepage` package's build and +// deploy (release 11 slice O4; a row of the Publish panel since release 18), +// the twin of the Hub row. // -// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in -// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the -// primary checkout it holds the live homepage's build — so a deploy clicked -// from a spec would be a real one. So: +// NOTHING HERE BUILDS THE HOMEPAGE, AND NOTHING HERE DEPLOYS IT. The homepage's +// build publishes the source mirror (the operator's scrub and denylist files) +// into the CHECKOUT's own homepage/out — in the primary checkout, the live +// homepage's build. So: // - no spec clicks Deploy homepage, and none ticks Deploy after build and // then clicks Build homepage; -// - the two specs that click Build homepage first hold BOTH the `build` and -// the `deploy` queue with fabricated jobs (/api/test/stuck-job, never -// released here), so the job each starts only ever QUEUES — the -// build-homepage it expects on `build`, and equally a build-deploy-homepage -// on `deploy` if a regression turned the click into a build-and-deploy — -// and it is cancelled while still queued: from its own lane, or (the -// re-render spec) by the harness reset. Its start function never runs: no -// log file, no child, nothing written to homepage/public or homepage/out. +// - the two specs that click Build homepage first hold the `publish` queue +// with a fabricated job (/api/test/stuck-job, never released here), so the +// stages the click enqueues — the index update (no index yet), then +// build-homepage — only ever QUEUE, and are cancelled while still queued: +// from the row's lane (a cancel of the run's first stage cancels the rest +// of the run), or (the re-render spec) by the harness reset. No stage +// starts: no log file, no child, nothing written to homepage/out. // If a spec fails before its cancel, the next resetData cancels jobs newest -// first, so the queued job is removed before a holder's slot is freed. +// first, so the queued stages are removed before the holder's slot is freed. import { readdir } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; @@ -39,8 +39,7 @@ const buildButton = (page: Page) => group(page).getByRole("button", { name: "Build homepage", exact: true }); const deployButton = (page: Page) => group(page).getByRole("button", { name: "Deploy homepage", exact: true }); -// Scoped: /sites has three other "Deploy after build" checkboxes (the batch, -// the specific-sites panel, the hub). +// Scoped: the Hub row has a "Deploy after build" checkbox too. const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build"); const previewInput = (page: Page) => group(page).getByRole("textbox", { name: "preview branch", exact: true }); @@ -80,13 +79,16 @@ test.beforeEach(async () => { await resetData("empty"); }); -test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({ +test("the Homepage row follows Hub's in the Publish panel, with the build and deploy controls and what a deploy ships", async ({ page, }) => { await page.goto("/sites"); - const hub = page.getByRole("heading", { name: "Hub", exact: true }); - const home = page.getByRole("heading", { name: "Homepage", exact: true }); + const panel = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Publish", exact: true }), + }); + const hub = panel.getByRole("heading", { name: "Hub", exact: true }); + const home = panel.getByRole("heading", { name: "Homepage", exact: true }); await expect(home).toBeVisible(); expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y); @@ -98,29 +100,22 @@ test("the Homepage section follows Hub, with the build and deploy controls and w // Empty is production. await expect(previewInput(page)).toHaveValue(""); - // One line: what homepage/out holds, the project, production, the live URL. + // One line: what the homepage's last build is, the project, production, the + // live URL. Its build is read from its stamp (_homepage/built.json), which a + // fresh test corpus has none of. const ships = group(page).getByTestId("homepage-ships"); - if (await pathExists("../homepage/out/index.html")) { - await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /); - } else { - await expect(ships).toContainText( - "homepage/out holds no build yet, so Deploy homepage has nothing to ship.", - ); - } + await expect(ships).toContainText( + "homepage/out holds no build yet, so Deploy homepage has nothing to ship.", + ); await expect(ships).toContainText("to archilyzer (production)"); await expect( ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }), ).toHaveAttribute("href", "https://archilyzer.pages.dev"); - await expect(group(page)).toContainText( - "The homepage reads the search index as it stands: run Build index", - ); - - // The homepage's prose moved out of the Hub section and into its own. - const hubSection = page.locator("section").filter({ has: hub }); - const homeSection = page.locator("section").filter({ has: home }); - await expect(hubSection).not.toContainText("archilyzer deploy homepage"); - await expect(homeSection).toContainText("archilyzer deploy homepage"); - await expect(homeSection).toContainText("homepage/out"); + // "Run Build index first" is the row's chips now: the index it would be + // built from, and its build waiting for it. + await expect(group(page).locator('[data-chip="index"]')).toContainText("no index yet"); + await expect(group(page).locator('[data-chip="built"]')).toContainText("update the index first"); + await expect(group(page)).toContainText("Build homepage also publishes the source mirror"); }); test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({ @@ -165,24 +160,20 @@ test("a bad preview name greys out every deploy and says why; a good one shows i await previewInput(page).fill(""); await expect(ships).toContainText("to archilyzer (production)"); - // Nothing above was clicked: no homepage job of any kind exists. - for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) { + // Nothing above was clicked: no publish stage of any kind exists. + for (const kind of ["publish-update-index", "publish-build-homepage", "publish-deploy-homepage"]) { expect(await metasOfKind(kind), kind).toEqual([]); } }); -test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({ +test("Build homepage enqueues the index update, then build-homepage, on the publish queue (held there, then cancelled from its lane)", async ({ page, request, }) => { - // See the header: with both queues held, whatever the click starts can only - // queue. - for (const queue of ["build", "deploy"]) { - const hold = await request.get( - `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, - ); - expect(hold.ok(), queue).toBe(true); - } + // See the header: with the publish queue held, whatever the click starts + // can only queue. + const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); + expect(hold.ok()).toBe(true); await openSites(page); await expect(deployAfter(page)).not.toBeChecked(); @@ -192,13 +183,15 @@ test("Build homepage starts a build-homepage job on the build queue (held there, await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); await expect(group(page).getByRole("alert")).toHaveCount(0); - // One build-homepage job, on the build queue, queued — and nothing else. - await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1); - const [job] = await metasOfKind("build-homepage"); - expect(job.queueKey).toBe("build"); + // One run: the index update (there is no index yet) and one build-homepage, + // both on the publish queue, queued — and no deploy. + await expect.poll(async () => (await metasOfKind("publish-build-homepage")).length).toBe(1); + const [job] = await metasOfKind("publish-build-homepage"); + expect(job.queueKey).toBe("publish"); expect(job.status).toBe("queued"); - expect(await metasOfKind("build-deploy-homepage")).toEqual([]); - expect(await metasOfKind("deploy-homepage")).toEqual([]); + const [index] = await metasOfKind("publish-update-index"); + expect(index?.status).toBe("queued"); + expect(await metasOfKind("publish-deploy-homepage")).toEqual([]); await group(page).getByRole("button", { name: "Cancel Build homepage" }).click(); await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible(); @@ -217,7 +210,11 @@ test("Build homepage starts a build-homepage job on the build queue (held there, // rewrote the meta, so a restart's boot pass could re-queue a job the // operator had cancelled, and a cancelled clip fetch could read as queued. await expect - .poll(async () => (await metasOfKind("build-homepage"))[0]?.status) + .poll(async () => (await metasOfKind("publish-build-homepage"))[0]?.status) + .toBe("cancelled"); + // The run's Cancel was its first stage's: the rest of the run went with it. + await expect + .poll(async () => (await metasOfKind("publish-update-index"))[0]?.status) .toBe("cancelled"); }); @@ -225,7 +222,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is page, request, }) => { - // The line reads homepage/out when /sites renders, and nothing here may + // The line reads the homepage's stamp when /sites renders, and nothing here may // build (see the header) — so the proof is that the page RE-RENDERS when the // lane ends: a site written to disk after the page loaded appears without a // reload. Passive refresh is off (it would re-render on the job's status @@ -233,12 +230,8 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is // the harness reset, because the lane's own Cancel is a server action that // revalidates, and its response re-renders the page on its own. await writeSettings({ autoRefreshIntervalSeconds: 0 }); - for (const queue of ["build", "deploy"]) { - const hold = await request.get( - `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, - ); - expect(hold.ok(), queue).toBe(true); - } + const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); + expect(hold.ok()).toBe(true); await openSites(page); await buildButton(page).click(); @@ -248,7 +241,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is const probe = page.locator('a[href="/sites/refresh-probe"]'); await expect(probe).toHaveCount(0); - // Cancels every live job, newest first: the queued build before either + // Cancels every live job, newest first: the queued stages before the // holder's slot is freed, so nothing is promoted and nothing builds. const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`); expect(reset.ok()).toBe(true); diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts @@ -28,7 +28,10 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // E2E_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare // refusing the API token ("Authentication error // [code: 10000]"), for the deploy stage's -// "[deploy] REFUSED by Cloudflare" sentence +// "[deploy] REFUSED by Cloudflare" sentence — the +// fallback of its mode sidecar, which a spec +// writes (`<exportBuildsDir>/.fake-wrangler- +// mode.json` `{"authFail": true}`) // E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and // records "skipped" (common/publish/liveCheck.ts): // the fake wrangler deploys nothing to read. Set @@ -42,6 +45,21 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // (common/lib/pagesDeploy.ts wranglerBin), pointed below at // e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake // writes its argv to `.fake-wrangler.json` beside the bundle it was handed. +// Three more product variables the publish stages read are set for the test +// server (release 18 S4): +// ARCHILYZER_BRANCH=main the branch a build stamps (stamps.ts: the env +// wins over git). A worktree's branch is never +// main, and production refuses a build of any +// other — without it every production deploy in +// the suite would be refused +// CLOUDFLARE_API_TOKEN a dummy: the deploy stage's credential +// preflight asks for a token or a wrangler login +// on disk; the fake wrangler never sends it, and +// the suite must not depend on the host's login +// EXPORT_NEXT_BIN e2e/fixtures/bin/fake-next.mjs: a site's and +// the hub's build run compose for real, then this +// in place of `next build` (it copies the composed +// public dir to export/out) // // The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per // worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor @@ -56,6 +74,9 @@ const E2E_SERVER_ENV = { E2E_AUDIO_CHECK_RECOVER_AFTER: "2", E2E_LIVE_CHECK: "skip", WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"), + ARCHILYZER_BRANCH: "main", + CLOUDFLARE_API_TOKEN: "e2e-fake-token-never-sent", + EXPORT_NEXT_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-next.mjs"), }; const PORT = portFor("PORT");