363 lines
19 KiB
JavaScript
363 lines
19 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", "help"]) {
|
|
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("repository context, tabs and content never overlap in a compact workspace", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1024, height: 768 });
|
|
await page.locator('[data-action="select-repo"][data-deployment-count]:not([data-deployment-count="0"])').first().click();
|
|
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
|
|
const layout = await page.evaluate(() => {
|
|
const context = document.querySelector(".repo-context")?.getBoundingClientRect();
|
|
const tabs = document.querySelector(".tabs")?.getBoundingClientRect();
|
|
const content = document.querySelector(".repo-content")?.getBoundingClientRect();
|
|
return {
|
|
contextEndsBeforeTabs: Boolean(context && tabs && context.bottom <= tabs.top + 0.5),
|
|
tabsEndBeforeContent: Boolean(tabs && content && tabs.bottom <= content.top + 0.5),
|
|
horizontalTabFallback: Boolean(tabs && document.querySelector(".tabs").scrollWidth >= document.querySelector(".tabs").clientWidth),
|
|
};
|
|
});
|
|
expect(layout).toEqual({ contextEndsBeforeTabs: true, tabsEndBeforeContent: true, horizontalTabFallback: true });
|
|
});
|
|
|
|
test("Help center is searchable and contextual guidance opens the requested topic", async ({ page }) => {
|
|
await page.locator('.nav-button[data-view="help"]').click();
|
|
await expect(page.getByRole("heading", { name: "How can we help?" })).toBeVisible();
|
|
await expect(page.locator(".help-topic")).toHaveCount(8);
|
|
await page.locator("#help-search").fill("deploy key");
|
|
await expect(page.locator(".help-topic")).toHaveCount(1);
|
|
await expect(page.locator(".help-topic")).toContainText("Repair repository deploy keys");
|
|
await page.locator('[data-action="select-repo"]').first().click();
|
|
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
|
|
await page.locator('[data-action="open-context-help"][data-topic="workspace-sync"]').click();
|
|
await expect(page.locator('[data-help-topic="workspace-sync"]')).toHaveAttribute("open", "");
|
|
await expect(page.locator('[data-help-topic="workspace-sync"]')).toContainText("Make a local project match Gitea");
|
|
await assertSurface(page);
|
|
});
|
|
|
|
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
|
|
for (const view of ["overview", "deployments", "diagnostics", "settings", "help"]) {
|
|
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();
|
|
// Selecting a repository loads its diff asynchronously; that load would
|
|
// otherwise overwrite the diff injected below.
|
|
await expect.poll(() => page.evaluate(() => Boolean(ui.diff) && !ui.diff.startsWith("Loading"))).toBe(true);
|
|
|
|
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);
|
|
});
|