Files
SnapOtter/tests/qa/pipeline-ui.qa.spec.ts
SnapOtterandGitHub 51022628dc fix(a11y): WCAG AA contrast retune for the Otter Orange palette (#567)
Fixes #557. Vivid fill, ink label: brand #E07832 stays on fills while primary-foreground flips to #1A1814 (5.83:1); new theme-aware ink tokens carry orange, destructive, and success text roles; opacity-modified text purged; landing, demo, and the docs fund button retuned. Guarded by a CSS-parsing unit contrast test, rebuilt axe baselines with zero contrast entries, a new landing axe smoke, and fully regenerated darwin visual baselines.
2026-07-18 12:56:48 +08:00

258 lines
9.5 KiB
TypeScript

/**
* Pipeline Builder UI QA spec.
*
* Drives the /automate page in the QA container (auth off, port 13499).
* Verifies: add steps, configure settings, run pipeline, progress UI,
* downloadable result, and no console errors.
*/
import { expect, type Page, test } from "@playwright/test";
import { fixture, instrument, isClean, issuesSummary, uploadFiles } from "./qa-helpers";
const FIXTURE_PNG = fixture("image", "formats", "sample.png");
/**
* Helper: click a tool in the left palette by searching for it and clicking
* the palette item whose name-div contains the given text.
* The palette ToolItem structure is:
* <button> ... <div class="text-sm font-medium ...">ToolName</div> ... </button>
*/
async function addToolStep(page: Page, searchText: string, nameMatch: RegExp) {
const searchInput = page.locator("input[placeholder]").first();
await searchInput.fill(searchText);
await page.waitForTimeout(600);
// Target the palette button that has a .font-medium child matching the name
const paletteBtn = page
.locator(".overflow-y-auto button")
.filter({ has: page.locator(".font-medium", { hasText: nameMatch }) })
.first();
await paletteBtn.click({ timeout: 10_000 });
await searchInput.clear();
await page.waitForTimeout(300);
}
test.describe("Pipeline Builder UI", () => {
test.setTimeout(180_000);
test("build 3-step pipeline (resize + grayscale + convert webp), run, confirm progress + download", async ({
page,
}) => {
const issues = instrument(page);
// Navigate to the automate page
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
// ---- Upload a file via the semantic upload control ----
await uploadFiles(page, FIXTURE_PNG);
// Wait for file badge
await expect(page.locator("text=sample.png").first()).toBeVisible({ timeout: 10_000 });
// ---- Add 3 steps from the tool palette ----
await addToolStep(page, "Resize", /^Resize$/);
await addToolStep(page, "Adjust Colors", /^Adjust Colors$/);
await addToolStep(page, "Convert", /^Convert$/);
// Verify 3 steps shown
await expect(page.locator("text=3 steps configured")).toBeVisible({ timeout: 5_000 });
// ---- Configure step settings ----
// Expand step 1 (Resize) -- it is the first [role="button"] in the builder area (right pane)
// The builder steps are inside the right pane, each is a [role="button"] with the tool name
const builderArea = page.locator(".flex-1.flex.flex-col.overflow-hidden");
const step1Header = builderArea
.locator("[role='button']")
.filter({ hasText: /Resize/ })
.first();
await step1Header.click();
await page.waitForTimeout(500);
// Find the Width input -- it should be labeled "Width" and be a number input
const widthInput = page.locator("input[placeholder='Auto']").first();
if (await widthInput.isVisible({ timeout: 3_000 }).catch(() => false)) {
await widthInput.fill("100");
}
// Expand step 2 (Adjust Colors)
const step2Header = builderArea
.locator("[role='button']")
.filter({ hasText: /Adjust Colors/ })
.first();
await step2Header.click();
await page.waitForTimeout(800);
// Set the Effect dropdown to grayscale
const effectSelect = page
.locator("select")
.filter({ has: page.locator("option[value='grayscale']") })
.first();
if (await effectSelect.isVisible({ timeout: 3_000 }).catch(() => false)) {
await effectSelect.selectOption("grayscale");
}
// Expand step 3 (Convert)
const step3Header = builderArea
.locator("[role='button']")
.filter({ hasText: /Convert/ })
.first();
await step3Header.click();
await page.waitForTimeout(800);
// Set format to webp
const formatSelect = page
.locator("select")
.filter({ has: page.locator("option[value='webp']") })
.first();
if (await formatSelect.isVisible({ timeout: 3_000 }).catch(() => false)) {
await formatSelect.selectOption("webp");
}
// ---- Run the pipeline ----
const processBtn = page.getByRole("button", { name: /Process/i }).first();
await expect(processBtn).toBeEnabled({ timeout: 5_000 });
await processBtn.click();
// ---- Wait for completion ----
// Process button becomes enabled again after pipeline completes
await expect(processBtn).toBeEnabled({ timeout: 120_000 });
await page.waitForTimeout(1_000);
// ---- Verify the preview updated ----
// After processing, the preview panel should show size info (arrow between sizes)
const sizeInfo = page.locator("text=/\\u2192|\\u2794|\\u279c/").first(); // right arrow chars
const hasPreview = await sizeInfo.isVisible({ timeout: 10_000 }).catch(() => false);
if (!hasPreview) {
// Also check for error
const errorEl = page.locator(".text-destructive-ink, .text-red-500").first();
const errorText = await errorEl.textContent().catch(() => null);
if (errorText) {
console.log(`Pipeline UI error displayed: ${errorText}`);
}
}
// ---- Assert no console errors / 5xx ----
const clean = isClean(issues);
if (!clean) {
console.log("Page issues:", issuesSummary(issues));
}
// Allow console warnings but fail on 5xx server errors
expect(issues.serverErrors, "No server 5xx errors").toHaveLength(0);
});
test("save and load a pipeline from the builder", async ({ page }) => {
const issues = instrument(page);
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
// Add 2 steps
await addToolStep(page, "Resize", /^Resize$/);
await addToolStep(page, "Border", /^Border/);
await expect(page.locator("text=2 steps configured")).toBeVisible({ timeout: 5_000 });
// Click Save button
const saveBtn = page.getByRole("button", { name: /Save/i }).first();
await saveBtn.click();
// Fill in the pipeline name -- the save form has placeholder "Pipeline Name"
const nameInput = page.getByPlaceholder("Pipeline Name");
await nameInput.fill("QA UI Test Pipeline");
// Click the confirm save button (the one inside the save form)
const confirmSave = page
.locator("button")
.filter({ hasText: /^Save$/ })
.first();
await confirmSave.click();
// Wait for save
await page.waitForTimeout(2_000);
// Verify it appears in the saved list (bottom of left pane)
const savedItem = page.locator("text=QA UI Test Pipeline").first();
const isSaved = await savedItem.isVisible({ timeout: 5_000 }).catch(() => false);
if (isSaved) {
// Load it
await savedItem.click();
await page.waitForTimeout(1_000);
await expect(page.locator("text=2 steps configured")).toBeVisible({ timeout: 5_000 });
}
// Cleanup
const deleteBtn = page.locator("button[title='Delete pipeline']").first();
if (await deleteBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await deleteBtn.click();
await page.waitForTimeout(1_000);
}
expect(issues.serverErrors, "No server 5xx errors").toHaveLength(0);
});
test("pipeline builder shows empty state and add-tools prompt", async ({ page }) => {
const issues = instrument(page);
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
// Verify empty state
await expect(page.locator("text=/add tools|click to add/i").first()).toBeVisible({
timeout: 5_000,
});
// Process button disabled (no steps, no file)
const processBtn = page.getByRole("button", { name: /Process/i }).first();
await expect(processBtn).toBeDisabled();
expect(issues.serverErrors, "No server 5xx errors").toHaveLength(0);
});
test("removing a step updates the builder count", async ({ page }) => {
const issues = instrument(page);
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
// Add 2 steps
await addToolStep(page, "Resize", /^Resize$/);
await addToolStep(page, "Compress", /^Compress$/);
await expect(page.locator("text=2 steps configured")).toBeVisible({ timeout: 5_000 });
// Click the remove button (X icon) on the first step
// The remove button has title="Remove step" from the builder component
const removeBtn = page
.locator("button")
.filter({ has: page.locator(".lucide-x") })
.first();
await expect(removeBtn).toBeVisible({ timeout: 3_000 });
await removeBtn.click();
await page.waitForTimeout(500);
await expect(page.locator("text=1 step configured")).toBeVisible({ timeout: 5_000 });
expect(issues.serverErrors, "No server 5xx errors").toHaveLength(0);
});
test("process button disabled when no file uploaded", async ({ page }) => {
const issues = instrument(page);
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
await addToolStep(page, "Resize", /^Resize$/);
await expect(page.locator("text=1 step configured")).toBeVisible({ timeout: 5_000 });
// No file => button disabled
const processBtn = page.getByRole("button", { name: /Process/i }).first();
await expect(processBtn).toBeDisabled();
expect(issues.serverErrors, "No server 5xx errors").toHaveLength(0);
});
});