Two failure modes that only show up under conditions the tests never reached. renderDiff built one span per diff line with no bound. A regenerated lock file is an ordinary change: 50,000 lines produce 4 MB of markup and 50,000 elements that then have to be parsed and laid out inside the full shell replacement, and 200,000 lines produce 16 MB. The rendered view now stops at 2,000 lines and says how many were left out; ui.diff keeps the whole change, so Copy diff, the editor and hunk staging are unaffected. The line scan also runs once now instead of three times. withClient registered the connection error handler with once(). A connection that fails and then emits a second error while it is being torn down - a reset during client.end() is the ordinary case - leaves that event unhandled, and an unhandled 'error' on an EventEmitter reaches the uncaughtException handler, which calls app.exit(1). The handler stays attached and ignores anything after the first failure. Both are covered by tests that were confirmed to fail without the fix, together with the SSH paths that had none: host key mismatch reporting, the trusted fingerprint requirement for exec and upload, and remote upload path validation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
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("a very large diff is capped instead of freezing the window", async ({ page }) => {
|
|
const selected = await page.evaluate(() => {
|
|
const withChanges = ui.repositories.find((repository) => repository.localStatus?.counts?.changed);
|
|
if (!withChanges) return null;
|
|
selectRepository(withChanges.id);
|
|
return withChanges.fullName;
|
|
});
|
|
expect(selected, "the demo needs a repository with local changes").not.toBeNull();
|
|
await expect(page.locator(".diff-view")).toBeVisible();
|
|
|
|
const measured = await page.evaluate(() => {
|
|
const newline = String.fromCharCode(10);
|
|
const lines = ["diff --git a/package-lock.json b/package-lock.json"];
|
|
for (let index = 0; index < 40_000; index += 1) lines.push(`+ "package-${index}": "^1.2.3",`);
|
|
ui.diff = lines.join(newline);
|
|
ui.repositoryTab = "changes";
|
|
const started = performance.now();
|
|
render();
|
|
return {
|
|
renderMs: performance.now() - started,
|
|
rendered: document.querySelectorAll(".diff-line").length,
|
|
storedLines: ui.diff.split(newline).length,
|
|
};
|
|
});
|
|
|
|
expect(measured.storedLines).toBe(40_001);
|
|
expect(measured.rendered).toBeLessThan(2100);
|
|
expect(measured.renderMs).toBeLessThan(3000);
|
|
await expect(page.locator(".diff-view")).toContainText("more lines are not shown");
|
|
});
|
|
|
|
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);
|
|
});
|