mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Add ~500 new E2E tests and ~300 new integration tests covering: - 24 new GUI E2E specs: navigation, responsive layout, keyboard shortcuts, tool UI for all 35 non-AI tools, batch/pipeline workflows, settings/RBAC, visual regression, accessibility, and performance budgets - 3 new E2E-Docker specs: batch workflows, advanced pipelines, cross-format - 1 new adversarial integration test: memory pressure, corrupted files, unicode filenames, extreme dimensions, pipeline/batch edge cases - 29 expanded integration test files: HEIC/HEIF input, large files, parameter boundaries, batch processing, format edge cases across all tools - Cross-format matrix expanded: 641 tests covering every tool x 18 formats - AI bridge unit tests expanded: lifecycle, tool modules, error propagation - Unit test gaps filled: analytics, tool-registry, web stores Also fixes: - vitest.config.ts: exclude e2e-docs and e2e-landing from Vitest runner - AI E2E specs: add sidecar health check to skip gracefully when Python AI backend is not running instead of timing out
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { expect, test } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GUI Performance: Page load budgets, SPA navigation, interaction responsiveness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page Load Performance
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Page Load Performance", () => {
|
|
test("home page loads within budget (DOMContentLoaded < 2000ms)", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Navigate away first so we can measure a fresh load
|
|
await page.goto("about:blank");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
const loadTime = Date.now() - start;
|
|
|
|
expect(loadTime).toBeLessThan(2000);
|
|
});
|
|
|
|
test("home page navigation timing (FCP proxy < 2000ms)", async ({ loggedInPage: page }) => {
|
|
await page.goto("about:blank");
|
|
await page.goto("/");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
const perfEntries = await page.evaluate(() => {
|
|
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
|
|
return {
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
|
loadComplete: nav.loadEventEnd - nav.startTime,
|
|
domInteractive: nav.domInteractive - nav.startTime,
|
|
};
|
|
});
|
|
|
|
expect(perfEntries.domContentLoaded).toBeLessThan(2000);
|
|
expect(perfEntries.domInteractive).toBeLessThan(2000);
|
|
});
|
|
|
|
test("tool page loads within budget (DOMContentLoaded < 2000ms)", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("about:blank");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
const loadTime = Date.now() - start;
|
|
|
|
expect(loadTime).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SPA Navigation Timing
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("SPA Navigation Timing", () => {
|
|
test("navigate from / to /resize completes within 500ms", async ({ loggedInPage: page }) => {
|
|
// Start on home page and wait for it to settle
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
// Wait for the tool name to appear as a signal the route rendered
|
|
await page.locator("h2").filter({ hasText: "Resize" }).waitFor({ state: "visible" });
|
|
const navTime = Date.now() - start;
|
|
|
|
expect(navTime).toBeLessThan(2000);
|
|
});
|
|
|
|
test("navigate from /resize to /compress completes within 2000ms", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/compress");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
await page.locator("h2").filter({ hasText: "Compress" }).waitFor({ state: "visible" });
|
|
const navTime = Date.now() - start;
|
|
|
|
expect(navTime).toBeLessThan(2000);
|
|
});
|
|
|
|
test("navigate from /compress to /convert completes within 2000ms", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/compress");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/convert");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
await page.locator("h2").filter({ hasText: "Convert" }).waitFor({ state: "visible" });
|
|
const navTime = Date.now() - start;
|
|
|
|
expect(navTime).toBeLessThan(2000);
|
|
});
|
|
|
|
test("sidebar navigation from / to /automate completes within 2000ms", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.locator("aside").getByText("Automate").click();
|
|
await page.waitForURL("/automate");
|
|
await page.getByText("Pipeline Builder").waitFor({ state: "visible" });
|
|
const navTime = Date.now() - start;
|
|
|
|
expect(navTime).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings Dialog Timing
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Settings Dialog Timing", () => {
|
|
test("settings dialog opens within 300ms", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.locator("aside").getByText("Settings").click();
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "visible" });
|
|
const openTime = Date.now() - start;
|
|
|
|
expect(openTime).toBeLessThan(300);
|
|
});
|
|
|
|
test("settings dialog closes within 300ms", async ({ loggedInPage: page }) => {
|
|
await page.locator("aside").getByText("Settings").click();
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "visible" });
|
|
|
|
const start = Date.now();
|
|
await page.keyboard.press("Escape");
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "hidden" });
|
|
const closeTime = Date.now() - start;
|
|
|
|
expect(closeTime).toBeLessThan(300);
|
|
});
|
|
|
|
test("switching settings tabs renders within 200ms", async ({ loggedInPage: page }) => {
|
|
await page.locator("aside").getByText("Settings").click();
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "visible" });
|
|
|
|
// Switch to About tab
|
|
const start = Date.now();
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
await page.locator("h3").filter({ hasText: "About" }).waitFor({ state: "visible" });
|
|
const switchTime = Date.now() - start;
|
|
|
|
expect(switchTime).toBeLessThan(200);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interaction Responsiveness
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Interaction Responsiveness", () => {
|
|
test("theme toggle applies within 200ms", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Get initial theme state
|
|
const initialHasClass = await page.evaluate(() =>
|
|
document.documentElement.classList.contains("dark"),
|
|
);
|
|
|
|
// Click the theme toggle button in the footer
|
|
const themeBtn = page.locator("button[title='Toggle Theme']");
|
|
await expect(themeBtn).toBeVisible({ timeout: 5_000 });
|
|
|
|
const start = Date.now();
|
|
await themeBtn.click();
|
|
|
|
// Wait for the dark class to toggle
|
|
await page.waitForFunction(
|
|
(hadDark: boolean) => document.documentElement.classList.contains("dark") !== hadDark,
|
|
initialHasClass,
|
|
{ timeout: 200 },
|
|
);
|
|
const toggleTime = Date.now() - start;
|
|
|
|
expect(toggleTime).toBeLessThan(200);
|
|
|
|
// Toggle back to restore original state
|
|
await themeBtn.click();
|
|
});
|
|
|
|
test("tool panel search filters results within 300ms", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const searchInput = page.getByPlaceholder(/search/i).first();
|
|
await expect(searchInput).toBeVisible();
|
|
|
|
const start = Date.now();
|
|
await searchInput.fill("resize");
|
|
|
|
// Wait for the filtered result to be visible
|
|
await page.getByText("Resize").first().waitFor({ state: "visible" });
|
|
const filterTime = Date.now() - start;
|
|
|
|
// 300ms is generous for a client-side filter
|
|
expect(filterTime).toBeLessThan(300);
|
|
});
|
|
|
|
test("tool panel search clears within 300ms", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const searchInput = page.getByPlaceholder(/search/i).first();
|
|
await searchInput.fill("resize");
|
|
await page.waitForTimeout(200);
|
|
|
|
const start = Date.now();
|
|
await searchInput.fill("");
|
|
|
|
// All categories should reappear
|
|
await page.getByText("Essentials").first().waitFor({ state: "visible" });
|
|
const clearTime = Date.now() - start;
|
|
|
|
expect(clearTime).toBeLessThan(300);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bundle & Resource Efficiency
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Bundle Efficiency", () => {
|
|
test("home page does not load excessive resources", async ({ loggedInPage: page }) => {
|
|
await page.goto("about:blank");
|
|
|
|
// Count network requests during page load
|
|
const requests: string[] = [];
|
|
page.on("request", (req) => {
|
|
if (req.resourceType() === "script" || req.resourceType() === "stylesheet") {
|
|
requests.push(req.url());
|
|
}
|
|
});
|
|
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// In dev mode Vite serves unbundled ESM modules, so count is higher
|
|
// than production. 200 is generous for dev; production would be < 50.
|
|
expect(requests.length).toBeLessThan(200);
|
|
});
|
|
|
|
test("lazy-loaded tool pages add minimal additional requests", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Track new requests when navigating to a tool page
|
|
const newRequests: string[] = [];
|
|
page.on("request", (req) => {
|
|
if (req.resourceType() === "script") {
|
|
newRequests.push(req.url());
|
|
}
|
|
});
|
|
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// In dev mode Vite serves unbundled ESM; more requests than production.
|
|
expect(newRequests.length).toBeLessThan(100);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repeated Operations Performance
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Repeated Operations Performance", () => {
|
|
test("5 sequential SPA navigations stay responsive", async ({ loggedInPage: page }) => {
|
|
const routes = ["/resize", "/crop", "/rotate", "/convert", "/compress"];
|
|
const timings: number[] = [];
|
|
|
|
// Warm up: ensure all chunks are cached
|
|
for (const route of routes) {
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
}
|
|
|
|
// Measure sequential navigations
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
for (const route of routes) {
|
|
const start = Date.now();
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
const elapsed = Date.now() - start;
|
|
timings.push(elapsed);
|
|
}
|
|
|
|
// Average navigation time should be under 2000ms in dev mode
|
|
const avg = timings.reduce((a, b) => a + b, 0) / timings.length;
|
|
expect(avg).toBeLessThan(2000);
|
|
|
|
// No individual navigation should exceed 3000ms (dev mode variability)
|
|
for (const t of timings) {
|
|
expect(t).toBeLessThan(3000);
|
|
}
|
|
});
|
|
|
|
test("settings dialog open/close cycle stays fast across iterations", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const timings: number[] = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const start = Date.now();
|
|
await page.locator("aside").getByText("Settings").click();
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "visible" });
|
|
timings.push(Date.now() - start);
|
|
|
|
await page.keyboard.press("Escape");
|
|
await page.locator("h2").filter({ hasText: "Settings" }).waitFor({ state: "hidden" });
|
|
}
|
|
|
|
// All iterations should be under 300ms
|
|
for (const t of timings) {
|
|
expect(t).toBeLessThan(300);
|
|
}
|
|
});
|
|
});
|