import { test, expect } from "@playwright/test"; import { writeFile } from "node:fs/promises"; const consoleEntries = new WeakMap(); const runtimeErrors = new WeakMap(); test.beforeEach(async ({ page }, testInfo) => { const logs = []; const errors = []; consoleEntries.set(page, logs); runtimeErrors.set(page, errors); await page.emulateMedia({ colorScheme: testInfo.project.metadata.theme, reducedMotion: testInfo.project.metadata.reduced ? "reduce" : "no-preference" }); page.on("console", (message) => logs.push({ type: message.type(), text: message.text() })); page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message, stack: error.stack })); await page.goto("/"); await expect(page.locator(".app-shell")).toBeVisible(); const theme = testInfo.project.metadata.theme; await page.evaluate((requested) => { document.documentElement.dataset.theme = requested; localStorage.setItem("forgeflow-demo-theme", requested); }, theme); }); test.afterEach(async ({ page }, testInfo) => { const logs = consoleEntries.get(page) || []; const errors = runtimeErrors.get(page) || []; if (testInfo.status !== testInfo.expectedStatus) { const prefix = testInfo.outputPath("failure"); await writeFile(`${prefix}-console.json`, JSON.stringify({ test: testInfo.title, project: testInfo.project.name, metadata: testInfo.project.metadata, logs, errors }, null, 2)); await writeFile(`${prefix}-dom.html`, await page.content()); await writeFile(`${prefix}-fixture.json`, JSON.stringify({ url: page.url(), viewport: page.viewportSize(), theme: await page.locator("html").getAttribute("data-theme") }, null, 2)); } expect(errors, "page errors").toEqual([]); expect(logs.filter((entry) => entry.type === "error"), "console errors").toEqual([]); }); async function assertSurface(page) { const audit = await page.evaluate(() => { const interactive = [...document.querySelectorAll('button,input,select,textarea,a[href],[role="button"]')].filter((element) => { const style = getComputedStyle(element); return style.display !== "none" && style.visibility !== "hidden" && element.getBoundingClientRect().width > 0; }); const unnamed = interactive.filter((element) => !String(element.getAttribute("aria-label") || element.getAttribute("title") || element.labels?.[0]?.textContent || element.textContent || element.value || "").trim()); const outside = interactive.filter((element) => { const rect = element.getBoundingClientRect(); const fixed = ["fixed", "sticky"].includes(getComputedStyle(element).position) || Boolean(element.closest('[role="dialog"]')); return rect.left < -1 || rect.right > innerWidth + 1 || (fixed && (rect.top < -1 || rect.bottom > innerHeight + 1)); }); const text = document.body.innerText; return { horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, unnamed: unnamed.map((element) => element.outerHTML.slice(0, 180)), outside: outside.map((element) => element.outerHTML.slice(0, 180)), badTokens: ["undefined", "[object Object]", "â", "Â", "Ã"].filter((token) => text.includes(token)), nullText: /(^|\s)null($|\s)/i.test(text), headings: [...document.querySelectorAll("h1,h2,h3")].map((heading) => Number(heading.tagName[1])), }; }); expect(audit.horizontalOverflow).toBe(false); expect(audit.unnamed).toEqual([]); expect(audit.outside).toEqual([]); expect(audit.badTokens).toEqual([]); expect(audit.nullText).toBe(false); expect(audit.headings.length).toBeGreaterThan(0); } async function assertScrollableWhenOverflowing(page, selector) { const target = page.locator(selector); await expect(target).toBeVisible(); await expect(target).toHaveCSS("overflow-y", /auto|scroll/); let metrics; await expect.poll(async () => { metrics = await target.evaluate((element) => ({ connected: element.isConnected, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })); return metrics.connected && metrics.clientHeight > 0; }).toBe(true); if (metrics.scrollHeight > metrics.clientHeight + 1) { await expect.poll(() => target.evaluate((element) => { if (element.scrollHeight <= element.clientHeight + 1) return 1; element.scrollTop = element.scrollHeight; return element.scrollTop; })).toBeGreaterThan(0); } } test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => { await assertSurface(page); for (const view of ["overview", "deployments", "settings"]) { await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click(); await expect(page.locator("main")).toBeVisible(); await assertSurface(page); } await expect(page.locator("html")).toHaveAttribute("data-theme", String(testInfo.project.metadata.theme)); if (testInfo.project.metadata.reduced) { expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); } }); test("repository changes, Git tools and Git Validator complete their primary flow", async ({ page }) => { await page.locator('[data-action="select-repo"]').first().click(); await expect(page.locator('[data-action="repo-tab"]')).toHaveCount(6); for (const tab of ["changes", "history", "deployments", "gittools", "validator", "settings"]) { const control = page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`); if (await control.count()) { await control.click(); await expect(control).toHaveClass(/active/); await assertSurface(page); } } await page.locator('[data-action="repo-tab"][data-tab="validator"]').click(); await expect(page.locator(".validator-score")).toBeVisible(); await assertScrollableWhenOverflowing(page, ".validator-page"); await expect(page.locator("#validator-policy")).toBeVisible(); await page.locator("#validator-policy").selectOption("production"); await expect(page.locator(".validator-hero")).toContainText(/Production policy/i); await expect(page.locator(".validator-hero")).toContainText(/review required/i); await page.keyboard.press("Tab"); await expect(page.locator(":focus")).toBeVisible(); }); test("every long application surface retains a working vertical scroll owner", async ({ page }) => { for (const view of ["overview", "deployments", "diagnostics", "settings"]) { await test.step(`${view} view scrolls`, async () => { const navigation = page.locator(`.nav-button[data-view="${view}"]`); await navigation.click(); await expect(navigation).toHaveClass(/active/); await assertScrollableWhenOverflowing(page, ".main-canvas"); }); } await page.locator('[data-action="select-repo"]').first().click(); for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) { await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click(); const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page"); if (await scrollRoot.count()) await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page"); } }); test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => { await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click(); await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible(); const cards = page.locator(".deploy-card, .server-inventory-panel .tool-row"); const count = await cards.count(); expect(count).toBeGreaterThan(0); for (let index = 0; index < Math.min(count, 25); index += 1) { await expect(cards.nth(index)).not.toHaveText(/^\s*$/); } const unresolved = page.locator(".tool-row", { hasText: "Legacy Worker" }); await expect(unresolved).toContainText("Link unresolved"); await expect(unresolved).not.toContainText(/^Linked$/); await expect(page.locator(".server-inventory-panel").first()).toContainText("1 unresolved"); const repositoryLink = page.locator('[data-action="open-deployment-link"]'); if (await repositoryLink.count()) { await repositoryLink.first().click(); await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/); await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible(); await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/); await expect(page.locator(".repository-workloads")).toBeVisible(); await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked"); } await assertSurface(page); }); test("dialogs expose semantics, labels, keyboard close and focus restoration", async ({ page }) => { await page.locator('[data-action="select-repo"]').first().click(); const trigger = page.locator('[data-action="edit-deployment-profile"], [data-action="add-deployment-profile"]').first(); if (await trigger.count()) { await trigger.focus(); await trigger.click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible(); await expect(dialog.locator("button").first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); } await assertSurface(page); }); test("onboarding and updater states remain usable without an existing configuration", async ({ page }) => { await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "false")); await page.reload(); await expect(page.locator(".setup-window")).toBeVisible(); await expect(page.getByRole("heading", { name: "Check this computer" })).toBeVisible(); await page.locator('[data-action="setup-run-preflight"]').click(); await expect(page.locator('[data-action="setup-continue"]')).toBeEnabled(); await assertSurface(page); await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "true")); await page.reload(); await page.locator('.nav-button[data-view="settings"]').click(); await page.locator('[data-action="check-updates"]').first().click(); await expect(page.locator(".update-card")).toContainText(/ForgeFlow/i); await assertSurface(page); }); test("inventory, deployment safety and failure evidence dialogs are reviewable", async ({ page }) => { await page.locator('.nav-button[data-view="deployments"]').click(); const reconciliation = page.locator('[data-action="plan-server-reconciliation"]').first(); if (await reconciliation.count()) { await reconciliation.click(); await expect(page.locator('[role="dialog"]')).toContainText(/reconciliation/i); await page.keyboard.press("Escape"); } const keyLifecycle = page.locator('[data-action="manage-deploy-key"]').first(); if (await keyLifecycle.count()) { await keyLifecycle.click(); await expect(page.locator('[role="dialog"]')).toContainText(/Deploy key lifecycle/i); await page.keyboard.press("Escape"); } const preflight = page.locator('[data-action="run-deployment-preflight"]').first(); await preflight.click(); await expect(page.locator('[role="dialog"]')).toContainText(/preflight/i); await page.keyboard.press("Escape"); const failed = page.locator('[data-action="open-operation"]').last(); if (await failed.count()) { await failed.click(); await expect(page.locator('[role="dialog"]')).toContainText(/failed|failure|healthcheck/i); await page.keyboard.press("Escape"); } await assertSurface(page); }); // A repository or deployment poll renders the whole shell again. Changing an // unrelated part of the state is what a poll effectively does, and it must not // take the caret or the scroll position away from the user. async function forceUnrelatedRerender(page) { await page.evaluate(() => { ui.diagnosticsStatus = { ...(ui.diagnosticsStatus || {}), enabled: !(ui.diagnosticsStatus?.enabled === false) }; render(); }); } test("a background refresh keeps typing and caret position intact", async ({ page }) => { const search = page.locator("#global-search"); await search.click(); await search.fill("Forge"); // Typing schedules a debounced render. Wait for it, otherwise the caret below // can land on the element that render is about to replace. await expect.poll(() => page.evaluate(() => ui.inputRenderTimer === null)).toBe(true); await search.evaluate((element) => element.setSelectionRange(1, 3)); await forceUnrelatedRerender(page); await expect(search).toBeFocused(); expect(await search.inputValue()).toBe("Forge"); expect(await search.evaluate((element) => [element.selectionStart, element.selectionEnd])).toEqual([1, 3]); }); test("a background refresh keeps scroll offsets intact", async ({ page }) => { await page.locator('.nav-button[data-action="navigate"][data-view="settings"]').click(); const canvas = page.locator(".main-canvas"); const scrolled = await canvas.evaluate((element) => { element.scrollTop = Math.min(120, Math.max(0, element.scrollHeight - element.clientHeight)); return element.scrollTop; }); expect(scrolled).toBeGreaterThan(0); await forceUnrelatedRerender(page); expect(await canvas.evaluate((element) => element.scrollTop)).toBe(scrolled); }); test("sections that used to be injected after render are part of the rendered markup", async ({ page }) => { await page.locator('.nav-button[data-action="navigate"][data-view="diagnostics"]').click(); const auditPanel = page.locator(".diagnostics-page .section-block", { hasText: "Operational audit log" }); await expect(auditPanel).toBeVisible(); await expect(auditPanel).toContainText("Load the operational audit log"); // The audit rows are state the shell renders itself now, so a plain render has // to pick them up without any post-render injection step. await page.evaluate(() => { ui.auditEvents = [{ timestamp: new Date().toISOString(), event: "deployment.requested", details: { repository: "Jens/Probe", result: "queued" } }]; render(); }); await expect(auditPanel.locator("table.data-table")).toContainText("Jens/Probe"); await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested"); }); test("an unchanged render leaves the existing DOM in place", async ({ page }) => { await page.locator('[data-action="select-repo"]').first().click(); const marked = await page.evaluate(() => { // Relative timestamps ("just now" turning into "1m ago") and pending async // state legitimately change the markup between two renders that are seconds // apart. Rendering twice inside one synchronous block removes that window, // so the second render can only be skipped because nothing changed. render(); document.querySelector(".repo-list").dataset.renderProbe = "kept"; render(); return document.querySelector(".repo-list")?.dataset.renderProbe || null; }); expect(marked).toBe("kept"); const replaced = await page.evaluate(() => { document.querySelector(".repo-list").dataset.renderProbe = "kept"; ui.repoSearch = `probe-${Date.now()}`; render(); return document.querySelector(".repo-list")?.dataset.renderProbe || null; }); expect(replaced).toBe(null); });