Archilyzer · Source

archilyzer

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

commit 0bdab05ed797d9f7076e06e2f271669835bbecf3
parent 7517df525fa5343fbc6d3e08034e20e7b10fd560
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 10 Oct 2026 01:31:20 -0400

e2e: the four slow timers on a test clock (e2e speed S3)

Two are product timers and take a test-only duration from the editor's test
server (E2E_SERVER_ENV), declared in envVars.ts:
- E2E_BACKOFF_BASE_MS (20 s; 60 s in production): the first rate-limit
  cooldown, read in platformBackoff.ts's nextBackoff. The cap and the hold
  arithmetic keep the real constants. pacing.spec still watches it lapse.
- E2E_CLIP_WINDOW_GAP_MS (2 s; 30–45 s): the pause between two clip-window
  fetches, read in fetchWindows.ts. fetch-window.spec now asserts the one pause
  the batch owes, from the job log.

Two were never product timers: publish-lane.spec's 45 s and channel-storage
.spec's 25 s were the stuck-job holder's own release timer, sized for a cold
dev server. The route takes `release=<id>` (finished detached, as
releaseAfterMs is), and the specs let the holder go once they have seen what
it holds.

The stamp also stops counting the changelogs, read at run time only.

Before/after, start mode, per test: pacing:195 66 s -> 22 s; publish-lane:78
50 s -> 6 s; fetch-window:337 34 s -> 3 s; channel-storage:872 28 s -> 3 s.

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

Diffstat:
MENVIRONMENT.md | 2++
Mcommon/controller/fetchWindows.test.ts | 21+++++++++++++++++++++
Mcommon/controller/fetchWindows.ts | 9+++++++++
Mcommon/jobs/platformBackoff.test.ts | 19+++++++++++++++++++
Mcommon/jobs/platformBackoff.ts | 11++++++++++-
Mcommon/lib/envVars.ts | 2++
Meditor/app/api/test/stuck-job/route.ts | 45+++++++++++++++++++++++++++++++++------------
Meditor/e2e/channel-storage.spec.ts | 10+++++++---
Meditor/e2e/fetch-window.spec.ts | 15++++++++++++---
Meditor/e2e/pacing.spec.ts | 6++++--
Meditor/e2e/publish-lane.spec.ts | 16++++++++++------
Meditor/playwright.config.ts | 13+++++++++++++
Mscripts/e2e-stamp.mjs | 4++++
13 files changed, 146 insertions(+), 27 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -202,6 +202,8 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one | `E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS` | the real floor | Likewise, the interval floor. | common/ytdlp/audioCheckedDownload.ts | | `E2E_AUDIO_CHECK_RECOVER_STEP_MS` | the real step | Likewise, the recovery step. | common/ytdlp/audioCheckedDownload.ts | | `E2E_AUDIO_CHECK_RECOVER_AFTER` | the real count | Likewise, the recovery count. | common/ytdlp/audioCheckedDownload.ts | +| `E2E_BACKOFF_BASE_MS` | `60000` (the real base) | The first rate-limit cooldown, which every doubling starts from; the cap and the hold arithmetic keep the real constants. The editor's e2e server sets 20 s, so pacing.spec watches one lapse. | common/jobs/platformBackoff.ts | +| `E2E_CLIP_WINDOW_GAP_MS` | the real gap (30–45 s, more for Rumble) | The pause between two clip-window fetches in one batch. The editor's e2e server sets 2 s, so fetch-window.spec sees the one it owes. | common/controller/fetchWindows.ts | | `E2E_AUDIO_CHECK_DEBUG_PAUSE_MS` | off | A debugging pause inside the audio check. | common/ytdlp/audioCheckedDownload.ts | | `E2E_FAKE_YTDLP_AUDIO_CHECK_MODE` | — | Fake yt-dlp: which audio-check scenario to act out. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | | `E2E_FAKE_YTDLP_CHUNK_DELAY_MS` | — | Fake yt-dlp: delay between written chunks. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | diff --git a/common/controller/fetchWindows.test.ts b/common/controller/fetchWindows.test.ts @@ -213,6 +213,27 @@ test("the default gap is the platform's, floored at the clip-window minimum and } }); +test("E2E_CLIP_WINDOW_GAP_MS replaces the default gap; an explicit gapMs still wins", async () => { + const saved = process.env.E2E_CLIP_WINDOW_GAP_MS; + try { + process.env.E2E_CLIP_WINDOW_GAP_MS = "2000"; + const h = harness(); + await fetchWindows({ + paths: await corpus(), + items: [item("v1", "/ok"), item("v2", "/ok")], + provenance: { requestedBy: "test" }, + deps: h.deps, + }); + assert.deepEqual(h.rec.sleeps, [2000]); + const explicit = harness(); + await go(await corpus(), [item("v1", "/ok"), item("v2", "/ok")], explicit, { gapMs: 1234 }); + assert.deepEqual(explicit.rec.sleeps, [1234]); + } finally { + if (saved === undefined) delete process.env.E2E_CLIP_WINDOW_GAP_MS; + else process.env.E2E_CLIP_WINDOW_GAP_MS = saved; + } +}); + test("an unknown channel, an unreadable one and a missing URL fail their items and the run goes on", async () => { const h = harness({ readChannelConfig: async (_p, slug) => (slug === "nope" ? null : CONFIG), diff --git a/common/controller/fetchWindows.ts b/common/controller/fetchWindows.ts @@ -90,6 +90,14 @@ export const CLIP_WINDOW_MIN_GAP_SECONDS = 30; export const CLIP_WINDOW_PLATFORM_MIN_GAP_SECONDS: Readonly<Record<string, number>> = Object.freeze({ rumble: 120 }); +// TEST-ONLY: E2E_CLIP_WINDOW_GAP_MS replaces the batch gap above, so the e2e +// suite sees the one owed pause without waiting 30–45 s for it +// (fetch-window.spec). Unset everywhere but the e2e test server. +function testGapMs(): number | undefined { + const raw = Number(process.env.E2E_CLIP_WINDOW_GAP_MS); + return Number.isFinite(raw) && raw > 0 ? raw : undefined; +} + export type FetchWindowsItem = { slug: string; id: string; @@ -372,6 +380,7 @@ export async function fetchWindows({ const paced = configForVideoUrl(config, url); const gap = gapMs ?? + testGapMs() ?? downloadGapMs( config.sleepBetweenDownloadsSeconds ?? settings.sleepBetweenDownloadsSeconds, channelPaceSeconds(paced), diff --git a/common/jobs/platformBackoff.test.ts b/common/jobs/platformBackoff.test.ts @@ -61,6 +61,25 @@ test("nextBackoff applies bounded jitter (±10%)", () => { assert.equal(high.until, Math.round(BACKOFF_BASE_MS * 1.1)); }); +test("E2E_BACKOFF_BASE_MS shortens the base; the cap and the hold arithmetic keep the real constants", () => { + const saved = process.env.E2E_BACKOFF_BASE_MS; + try { + process.env.E2E_BACKOFF_BASE_MS = "20000"; + const first = nextBackoff(undefined, 0, noJitter); + assert.equal(first.until, 20_000); + assert.equal(nextBackoff(first, 0, noJitter).until, 40_000); + let entry = first; + for (let i = 0; i < 20; i++) entry = nextBackoff(entry, 0, noJitter); + assert.equal(entry.until, BACKOFF_MAX_MS); + assert.equal(failsAtCap(FAILS_TO_REACH_CAP), 1); + process.env.E2E_BACKOFF_BASE_MS = "not a number"; + assert.equal(nextBackoff(undefined, 0, noJitter).until, BACKOFF_BASE_MS); + } finally { + if (saved === undefined) delete process.env.E2E_BACKOFF_BASE_MS; + else process.env.E2E_BACKOFF_BASE_MS = saved; + } +}); + test("isCoolingDown reflects the until window", () => { const state: PlatformBackoffState = { odysee: { until: 500, fails: 1 } }; assert.equal(isCoolingDown(state, "odysee", 499), true); diff --git a/common/jobs/platformBackoff.ts b/common/jobs/platformBackoff.ts @@ -25,6 +25,15 @@ export const BACKOFF_MAX_MS = 30 * 60_000; // 30 minutes // ±10% jitter so multiple platforms recovering at once don't sync up. const JITTER = 0.1; +// TEST-ONLY: E2E_BACKOFF_BASE_MS shortens the FIRST cooldown (and so every +// doubling from it), so the e2e suite can watch one lapse in seconds instead +// of a minute (pacing.spec). The cap and the hold arithmetic (FAILS_TO_REACH_CAP +// below) stay on the real constants. Unset everywhere but the e2e test server. +function backoffBaseMs(): number { + const raw = Number(process.env.E2E_BACKOFF_BASE_MS); + return Number.isFinite(raw) && raw > 0 ? raw : BACKOFF_BASE_MS; +} + // Compute the next backoff entry for a platform given its previous one (or // undefined for the first failure). `now` and `rand` are injected for // deterministic tests; `rand` defaults to Math.random and returns [0, 1). @@ -34,7 +43,7 @@ export function nextBackoff( rand: () => number = Math.random, ): PlatformBackoffEntry { const fails = (prev?.fails ?? 0) + 1; - const base = Math.min(BACKOFF_BASE_MS * 2 ** (fails - 1), BACKOFF_MAX_MS); + const base = Math.min(backoffBaseMs() * 2 ** (fails - 1), BACKOFF_MAX_MS); const jitter = 1 + (rand() * 2 - 1) * JITTER; return { until: now + Math.round(base * jitter), fails }; } diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -200,6 +200,8 @@ const DECLARED: EnvVarDecl[] = [ { name: "E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS", audience: "test", default: "the real floor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the interval floor." }, { name: "E2E_AUDIO_CHECK_RECOVER_STEP_MS", audience: "test", default: "the real step", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery step." }, { name: "E2E_AUDIO_CHECK_RECOVER_AFTER", audience: "test", default: "the real count", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery count." }, + { name: "E2E_BACKOFF_BASE_MS", audience: "test", default: "`60000` (the real base)", readBy: "common/jobs/platformBackoff.ts", doc: "The first rate-limit cooldown, which every doubling starts from; the cap and the hold arithmetic keep the real constants. The editor's e2e server sets 20 s, so pacing.spec watches one lapse." }, + { name: "E2E_CLIP_WINDOW_GAP_MS", audience: "test", default: "the real gap (30–45 s, more for Rumble)", readBy: "common/controller/fetchWindows.ts", doc: "The pause between two clip-window fetches in one batch. The editor's e2e server sets 2 s, so fetch-window.spec sees the one it owes." }, { name: "E2E_AUDIO_CHECK_DEBUG_PAUSE_MS", audience: "test", default: "off", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "A debugging pause inside the audio check." }, { name: "E2E_FAKE_YTDLP_AUDIO_CHECK_MODE", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: which audio-check scenario to act out." }, { name: "E2E_FAKE_YTDLP_CHUNK_DELAY_MS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: delay between written chunks." }, diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts @@ -22,10 +22,42 @@ export const dynamic = "force-dynamic"; // mounted unconditionally on the reasoning that the editor is a localhost admin // tool — but the operator's browser is inside the loopback, so an // unauthenticated GET that writes into the registry is CSRF-able. See _guard.ts. +// Run `fn` after `ms` FROM OUTSIDE ANY REQUEST (see `releaseAfterMs` below for +// why); resolves whether that worked. +async function armDetached(fn: () => void, ms: number): Promise<boolean> { + let detached = false; + try { + // A Next internal (typed, but not public API) — test harness only. + const { workAsyncStorage } = + await import("next/dist/server/app-render/work-async-storage.external"); + workAsyncStorage.exit(() => { + detached = workAsyncStorage.getStore() === undefined; + setTimeout(fn, ms); + }); + } catch { + setTimeout(fn, ms); + } + return detached; +} + export async function GET(request: Request) { const denied = testRouteDenied(); if (denied) return denied; const url = new URL(request.url); + + // `release=<id>`: finish a holder this route made NOW, the same detached way + // `releaseAfterMs` does — for a spec that knows when it is done holding, + // rather than one sizing a timer for the slowest machine it might run on + // (e2e speed S3: publish-lane.spec's 45 s, channel-storage.spec's 25 s). + const releaseId = url.searchParams.get("release"); + if (releaseId) { + const registry = getRegistry(); + const known = registry.get(releaseId) !== undefined; + const detached = known + ? await armDetached(() => registry.finalize(releaseId, "done"), 0) + : false; + return NextResponse.json({ id: releaseId, released: known, detached }); + } const queueKey = url.searchParams.get("queue") || "stuck-queue"; // `slug` (+ `task`): the fake job runs ON A CHANNEL, transcribing one video — // a writer the media move must refuse over and name (release 16 slice RM's @@ -78,18 +110,7 @@ export async function GET(request: Request) { const releaseAfterMs = Number(url.searchParams.get("releaseAfterMs") ?? ""); let detached = false; if (Number.isFinite(releaseAfterMs) && releaseAfterMs > 0) { - const release = () => registry.finalize(id, "done"); - try { - // A Next internal (typed, but not public API) — test harness only. - const { workAsyncStorage } = - await import("next/dist/server/app-render/work-async-storage.external"); - workAsyncStorage.exit(() => { - detached = workAsyncStorage.getStore() === undefined; - setTimeout(release, releaseAfterMs); - }); - } catch { - setTimeout(release, releaseAfterMs); - } + detached = await armDetached(() => registry.finalize(id, "done"), releaseAfterMs); } return NextResponse.json({ id, queueKey, detached }); diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -867,8 +867,8 @@ test("a move that starts while a job writes into the channel refuses, naming the // a writer "stopping", and the move waits for it; after it, the move is free. // // The writer is the stuck-job route's fake Transcribe all on this channel's -// video, cancelled from /jobs; its function "returns" when the route releases -// it, 25 s after it was made. +// video, cancelled from /jobs; its function "returns" when the spec releases +// it through the route, once the refusal is seen (it used to be a 25 s timer). test("a job cancelled but still stopping holds the move until it has stopped", async ({ page, }, testInfo) => { @@ -880,7 +880,7 @@ test("a job cancelled but still stopping holds the move until it has stopped", a await mkdir(root, { recursive: true }); const writer = await page.request.get( - `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-stopping")}&slug=${SLUG}&task=${VIDEO}&releaseAfterMs=25000`, + `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-stopping")}&slug=${SLUG}&task=${VIDEO}`, ); expect(writer.ok()).toBe(true); const writerId = ((await writer.json()) as { id: string }).id; @@ -911,6 +911,10 @@ test("a job cancelled but still stopping holds the move until it has stopped", a { timeout: 15_000 }, ); + // Its function returns now. + const released = await page.request.get(`${baseUrl}/api/test/stuck-job?release=${writerId}`); + expect(((await released.json()) as { released: boolean }).released).toBe(true); + // Once it has stopped, nothing holds the move. await expect(async () => { await page.goto(channelStage(SLUG, "storage")); diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts @@ -51,7 +51,8 @@ async function invocations(): Promise<string> { async function pollJob( request: APIRequestContext, jobId: string, - // A batch sleeps the clip-window gap (30–45 s) between two fetches. + // A batch sleeps the clip-window gap between two fetches (2 s on the test + // server, E2E_CLIP_WINDOW_GAP_MS; 30–45 s in production). timeout = 30_000, ): Promise<Record<string, unknown>> { let last: Record<string, unknown> = {}; @@ -332,8 +333,9 @@ test("a 429 fails the job and puts the platform in cooldown", async ({ // refuses with a 403, one that fetches. A single 403 is an item failure — a // removed Rumble page answers 403 too — so the run carries on past it and the // platform is NOT backed off; the job still ends failed, naming the window it -// lost. Exactly one pause is owed (between the two network fetches), at the -// clip-window floor of 30–45 s. +// lost. Exactly one pause is owed (between the two network fetches) — the +// clip-window floor of 30–45 s in production, 2 s on the test server +// (E2E_CLIP_WINDOW_GAP_MS, playwright.config.ts) — and the job log names it. test("a batch skips what is cached, survives one 403, and fetches the rest", async ({ request, }) => { @@ -373,6 +375,13 @@ test("a batch skips what is cached, survives one 403, and fetches the rest", asy const finished = await pollJob(request, body.jobId, 90_000); expect(finished.status).toBe("failed"); + const jobLog = await readFile( + resolvePath(`test-transcripts/.jobs/${body.jobs[0].jobId}.log`), + "utf8", + ); + expect(jobLog.match(/Sleeping \d+s before the next fetch/g)).toEqual([ + "Sleeping 2s before the next fetch", + ]); expect(String(finished.error)).toMatch(/1 window\(s\) failed to fetch/); expect(String(finished.error)).toMatch(/1 fetched, 0 cached, 1 failed/); diff --git a/editor/e2e/pacing.spec.ts b/editor/e2e/pacing.spec.ts @@ -195,8 +195,10 @@ test("a deferred video is skipped by auto-download, and the lane says so", async test("a live 429 backs youtube off, defers the video, and the next pick is the next video", async ({ request, }) => { - // A REAL cooldown: base 60 s ±10 %. The whole point is what happens when it - // lapses — the runner must move on to a2, not re-pick wp429vid1. + // A REAL cooldown, on a shorter clock: the base is 60 s ±10 % in production + // and 20 s on the test server (E2E_BACKOFF_BASE_MS, playwright.config.ts). + // The whole point is what happens when it lapses — the runner must move on to + // a2, not re-pick wp429vid1. test.setTimeout(150_000); await setup(["wp429vid1", "a2"]); diff --git a/editor/e2e/publish-lane.spec.ts b/editor/e2e/publish-lane.spec.ts @@ -86,13 +86,13 @@ test("enabled and started, the lane updates the index; a hold mid-stage stops th publish: { enabled: true, checkEveryMinutes: 1, refreshEveryMinutes: 0 }, }); await openLane(page); - // Hold the publish queue for a while, so the lane's first stage (the index - // update: there is no stamp) queues behind the holder and is IN FLIGHT from - // the lane's view while the hold is thrown. The holder lets go by itself — - // its clock starts here, after the page has compiled and hydrated, and runs - // long enough for the dispatch poll and the click below. - const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=45000`); + // Hold the publish queue, so the lane's first stage (the index update: there + // is no stamp) queues behind the holder and is IN FLIGHT from the lane's view + // while the hold is thrown. The spec lets the holder go once the hold is + // thrown (it used to be a 45 s timer sized for a cold dev server). + const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); + const holderId = ((await hold.json()) as { id: string }).id; await page.getByRole("button", { name: "Start publish lane" }).click(); await expect(page.getByText("Runner running")).toBeVisible({ timeout: 15_000 }); @@ -107,6 +107,10 @@ test("enabled and started, the lane updates the index; a hold mid-stage stops th expect((await publishStatus(request)).lane.held).toBe(true); const settings = await readJson<{ publish?: { held?: boolean } }>("test-settings.json"); expect(settings.publish?.held).toBe(true); + // The stage is still queued behind the holder; let the holder go. + expect((await metasOf("publish-update-index"))[0]?.status).not.toBe("done"); + const released = await request.get(`${baseUrl}/api/test/stuck-job?release=${holderId}`); + expect(((await released.json()) as { released: boolean }).released).toBe(true); // The stage is not killed: it runs once the holder lets go, and ends done. await expect diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts @@ -17,6 +17,17 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // shrink the mid-download audio check so a spec // sees it fire (common/ytdlp/audioCheckedDownload.ts) // E2E_AUDIO_CHECK_DEBUG_PAUSE_MS a debugging pause in the same check +// E2E_BACKOFF_BASE_MS the first rate-limit cooldown (60 s in +// production; 20 s here), so pacing.spec watches one +// lapse (common/jobs/platformBackoff.ts). The cap and +// the hold arithmetic keep the real constants. Not +// shorter: rumble-sweep, metadata-scan-softblock and +// fetch-window assert a cooldown is still in force a +// page load after it was recorded +// E2E_CLIP_WINDOW_GAP_MS the pause between two clip-window fetches in a +// batch (30–45 s in production; 2 s here), so +// fetch-window.spec sees the one it owes +// (common/controller/fetchWindows.ts) // E2E_MODE `start` (the default): the editor runs under // `next start` from a build the stamp vouches for // (scripts/e2e-stamp.mjs — rebuilt through the @@ -84,6 +95,8 @@ const E2E_SERVER_ENV = { E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS: "50", E2E_AUDIO_CHECK_RECOVER_STEP_MS: "100", E2E_AUDIO_CHECK_RECOVER_AFTER: "2", + E2E_BACKOFF_BASE_MS: "20000", + E2E_CLIP_WINDOW_GAP_MS: "2000", E2E_LIVE_CHECK: "skip", WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"), ARCHILYZER_BRANCH: "main", diff --git a/scripts/e2e-stamp.mjs b/scripts/e2e-stamp.mjs @@ -47,6 +47,10 @@ const COMMON_EXCLUDES = [ ":(exclude,glob)**/*.test.ts", ":(exclude,glob)**/*.test.mjs", ":(exclude,glob)**/*.test.tsx", + // Read at run time from EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE (the + // test server's are gitignored copies), never imported — and every record + // commit touches one. + ":(exclude,glob)**/CHANGELOG.md", ]; export const PACKAGES = Object.freeze({ editor: Object.freeze({