import { test, expect } from "@playwright/test"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; // The front door. Everything on it is read from the index and the job // registries; nothing on it shells out, and the tools panel proves that by // saying "not checked" until somebody presses the button. const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); const FIXTURE = path.join(UMTOOL, ".e2e-song"); test("the projects panel says exactly what /browse's header says", async ({ page }) => { await page.goto("/browse"); const header = (await page.locator("header .micro").last().textContent())?.trim(); expect(header).toMatch(/^\d+ projects · \d+ blocking · \d+ open$/); await page.goto("/"); await expect(page.locator("[data-projects-note]")).toHaveText(header!); // One row per kind, each with the six states as segments. await expect(page.locator("[data-state-row]").first()).toBeVisible(); const seg = page.locator("[data-state-seg]").first(); await expect(seg).toHaveAttribute("href", /\/browse\?kind=.+&state=\w+/); }); test("tools read 'not checked' until the button is pressed, and the stubs are found", async ({ page, request }) => { const before = await (await request.get("/api/doctor")).json(); // Runs first in this file, and nothing else in the suite probes. expect(before.checked).toBe(false); await page.goto("/"); await expect(page.locator("[data-tools-checked]")).toHaveAttribute("data-tools-checked", "never"); await page.locator("[data-action=doctor]").click(); // The fixture's YTDLP_BIN and QRENCODE_BIN are node stubs that answer // --version, so both are PRESENT with a version -- proof the probe read the // same env the pipeline does. await expect(page.locator("[data-tool=yt-dlp]")).toHaveAttribute("data-present", "1"); await expect(page.locator("[data-tool=qrencode]")).toHaveAttribute("data-present", "1"); await expect(page.locator("[data-tool=ffmpeg]")).toHaveAttribute("data-present", "1"); const after = await (await request.get("/api/doctor")).json(); expect(after.checked).toBe(true); expect(after.tools.find((t: { id: string }) => t.id === "yt-dlp").version).toContain("fixture"); }); test("umtool doctor reports a deliberately bad path as absent, and exits 1", () => { let code = 0; let stdout = ""; try { stdout = execFileSync("node", ["bin/umtool.mjs", "doctor", "--json"], { cwd: UMTOOL, encoding: "utf8", env: { ...process.env, YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode"), // The fixture's roots, never this shell's media root or cache (empty = unset). UMTOOL_MEDIA_DIR: "", UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), }, }); } catch (e) { const err = e as { status: number; stdout: string }; code = err.status; stdout = err.stdout; } const j = JSON.parse(stdout); const yt = j.tools.find((t: { id: string }) => t.id === "yt-dlp"); expect(yt.present).toBe(false); expect(yt.required).toBe(true); expect(j.ok).toBe(false); // A script can gate on it: the report pipeline cannot run without yt-dlp. expect(code).toBe(1); expect(j.tools.find((t: { id: string }) => t.id === "qrencode").present).toBe(true); }); test("/api/jobs has one shape over both registries", async ({ request }) => { const j = await (await request.get("/api/jobs")).json(); expect(j).toHaveProperty("running"); expect(Array.isArray(j.jobs)).toBe(true); for (const it of j.jobs) { expect(["chain", "render"]).toContain(it.source); expect(["running", "done", "failed"]).toContain(it.state); expect(typeof it.href).toBe("string"); } }); test("a build shows in Now, then in the activity list as done", async ({ page, request }) => { const start = await request.post("/api/report/build", { data: { project: "reports/dash-fixture", preset: "fast" }, }); expect(start.ok()).toBeTruthy(); const { job } = (await start.json()) as { job: { id: string } }; // Poll /api/jobs rather than the page: the offline build is ~3 s, and a // page load can land on either side of it. What must be true is that the // job is visible through the ONE endpoint while it runs, with its step. let seenRunning = false; for (let i = 0; i < 100; i += 1) { const j = (await (await request.get("/api/jobs")).json()) as { running: { id: string; progress?: { step: number; of: number } } | null; jobs: { id: string; state: string; href: string }[]; }; if (j.running?.id === job.id) { seenRunning = true; expect(j.running.progress?.of).toBeGreaterThan(0); } const mine = j.jobs.find((x) => x.id === job.id); if (mine && mine.state !== "running") { expect(mine.state).toBe("done"); expect(mine.href).toBe("/browse/reports/dash-fixture"); break; } await new Promise((r) => setTimeout(r, 200)); } expect(seenRunning).toBe(true); await page.goto("/"); const row = page.locator(`[data-activity-id="${job.id}"]`); await expect(row).toHaveAttribute("data-activity-state", "done"); await expect(row.getByRole("link")).toHaveAttribute("href", "/browse/reports/dash-fixture"); await expect(page.locator("[data-now=idle]")).toBeVisible(); }); test("the sources panel counts never-checked projects from the index, not from a probe", async ({ page }) => { await page.goto("/"); const counts = await page.locator("[data-sources-counts]").getAttribute("data-sources-counts"); const [never] = counts!.split("/").map(Number); // The fixture has report projects nothing has preflighted (report-fixture, // bench-fixture, the long-form ones); at least one is never-checked. expect(never).toBeGreaterThan(0); await expect(page.locator("[data-sources-list=never] [data-source-project='reports/longform-fixture']")).toBeVisible(); await expect(page.locator("[data-action=check-sources]")).toBeEnabled(); }); test("the dashboard opens the new-project menu prefilled from ?from=", async ({ page }) => { await page.goto("/?from=testchan%2Fvid1"); await expect(page.locator("[data-new-from]")).toHaveValue("testchan/vid1"); await expect(page.locator("[data-new-seed]")).toBeVisible(); });