mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: resolve 18 QA-discovered bugs across tools, previews, and the AI pipeline (#242)
Exhaustive QA sweep of all 157 tools. Fixes: CSP blob media, csv-excel ExcelJS interop, ocr-pdf segfault, chart-maker upload, non-PDF doc preview, RAW decode, merge-tool multi-file path, html-to-image chromium, ogv/wma/amr/ac3 preview fallbacks, meme/gif/stabilize codecs, nav+home a11y. Plus orphan-format and test-debt cleanup, the AI bundle build script, and a reusable Playwright QA harness under tests/qa/.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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 } from "./qa-helpers";
|
||||
|
||||
const FIXTURE_PNG = fixture("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 dropzone ----
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.locator("[class*='border-dashed']").first().click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles(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-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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user