mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
1. passport-photo 404 vs 501: add base route at /api/v1/tools/passport-photo
that returns 501 FEATURE_NOT_INSTALLED when the AI bundle is missing,
matching other AI tools. The /generate sub-route is Sharp-only (no
sidecar) so it correctly skips the isToolInstalled guard.
2. AuthGuard analytics consent race: don't evaluate shouldShowConsent()
until analyticsConfig has been fetched (guard on analyticsConfig !== null).
Prevents redirect to /analytics-consent before config is loaded.
3. Fragile sidebar Settings selector: add openSettings(page) helper to
E2E helpers that checks sidebar visibility with fallback to button role.
Replace all 134 occurrences of page.locator("aside").getByText("Settings")
across 18 test files.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import { expect, openSettings, test, uploadTestImage } 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 via Performance API (DOMContentLoaded < 2000ms)", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("about:blank");
|
|
await page.goto("/");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
const timing = await page.evaluate(() => {
|
|
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
|
|
return {
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
|
};
|
|
});
|
|
|
|
expect(timing.domContentLoaded).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("SPA navigation from home to tool completes under 1000ms (warmed)", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Warm up by visiting the target page first so modules are cached
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const start = Date.now();
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
const navTime = Date.now() - start;
|
|
|
|
// 1000ms budget for dev mode (500ms would be the production target)
|
|
expect(navTime).toBeLessThan(1000);
|
|
});
|
|
|
|
test("navigate from / to /resize completes within 2000ms", 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 openSettings(page);
|
|
const openTime = Date.now() - start;
|
|
|
|
expect(openTime).toBeLessThan(300);
|
|
});
|
|
|
|
test("settings dialog closes within 300ms", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
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 openSettings(page);
|
|
|
|
// 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("10 sequential tool navigations without crash", async ({ loggedInPage: page }) => {
|
|
const routes = [
|
|
"/resize",
|
|
"/crop",
|
|
"/rotate",
|
|
"/convert",
|
|
"/compress",
|
|
"/sharpening",
|
|
"/adjust-colors",
|
|
"/strip-metadata",
|
|
"/bulk-rename",
|
|
"/favicon",
|
|
];
|
|
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);
|
|
|
|
// Each page should render without errors
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
// 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("20x settings dialog open/close without degradation", async ({ loggedInPage: page }) => {
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const timings: number[] = [];
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const start = Date.now();
|
|
await openSettings(page);
|
|
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 complete under budget (generous for dev/CI)
|
|
for (const t of timings) {
|
|
expect(t).toBeLessThan(1000);
|
|
}
|
|
|
|
// Last open should not be significantly slower than first
|
|
const firstOpen = timings[0];
|
|
const lastOpen = timings[timings.length - 1];
|
|
expect(lastOpen).toBeLessThan(Math.max(firstOpen * 3, 1000));
|
|
});
|
|
|
|
test("10x upload/clear cycle stays responsive", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const timings: number[] = [];
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const start = Date.now();
|
|
|
|
// Upload
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Dropzone should reappear
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
|
|
timings.push(Date.now() - start);
|
|
}
|
|
|
|
// No individual cycle should be excessively slow
|
|
for (const t of timings) {
|
|
expect(t).toBeLessThan(10_000);
|
|
}
|
|
|
|
// The page should still be responsive after 10 cycles
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
});
|