test: expand test coverage across unit, integration, e2e, and e2e-docker suites

Add ~210 new tests filling gaps identified by a comprehensive 14-agent
coverage audit. Unit+integration tests go from 9,388 to 9,484 (all passing).

Unit tests (+36):
- AI bridge: OOM fallback path, custom tier option
- Web lib: api-errors, format date/datetime, tool-i18n coverage

Integration tests (+19):
- Format matrix: ai-canvas-expand and find-duplicates added to cross-format matrix
- Adversarial: SVG XXE attacks, SQL injection in settings, request body size
  limits, race conditions with identical filenames

E2E Docker (+3):
- ai-canvas-expand tool coverage with HEIC input and edge cases

E2E GUI (~150+):
- Navigation: login rate limiting, ai-canvas-expand in parameterized list
- Responsive: dropzone visibility, text readability, dialog bounds at all viewports
- Keyboard: shortcuts verified from automate, files, tool, and fullscreen pages
- Tool UI: undo/state-reset for 16 tools, crop canvas drag handles, rotate/border
  live preview, linked aspect-ratio inputs for resize
- Batch: per-image undo isolation, batch compress/convert/rotate (not just resize)
- Pipeline: tool palette search, step collapse/expand visibility
- Settings: audit log entry verification, system settings persistence, teams CRUD,
  role permission toggling
- RBAC: user/editor 403 on roles/teams endpoints, privilege escalation prevention,
  cross-role tab parity documented as intentional
- Accessibility: skip-to-content link (WCAG 2.4.1), comprehensive color contrast
  for all headings/body/buttons in both themes with DOM-walking background detection
- Resilience: auth expiry 401 redirect, rate limit 429 handling
- Performance: JS heap memory stability for tool navigation, dialog cycling,
  upload/clear cycles, rapid page navigation
This commit is contained in:
SnapOtter
2026-05-15 21:35:02 +08:00
parent d38621d7b9
commit 3b181dd1ac
24 changed files with 3398 additions and 0 deletions
+201
View File
@@ -904,3 +904,204 @@ test.describe("Memory and Stability - Extended", () => {
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
});
});
// ---------------------------------------------------------------------------
// 14.9 Memory Stability with JS Heap Measurement
// ---------------------------------------------------------------------------
test.describe("Memory Stability - Heap Measurement", () => {
test("10 tool navigations do not cause unbounded heap growth", async ({ loggedInPage: page }) => {
const tools = [
"/resize",
"/compress",
"/convert",
"/rotate",
"/flip",
"/crop",
"/watermark",
"/border",
"/sharpening",
"/adjust-colors",
];
// Warm up and take baseline
await page.goto("/resize");
await page.waitForLoadState("networkidle");
const baselineHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
// Navigate through all tools sequentially
for (const tool of tools) {
await page.goto(tool);
await page.waitForLoadState("domcontentloaded");
await expect(page.locator("aside")).toBeVisible();
}
const finalHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
// If heap measurement is available (Chromium only), verify no unbounded growth
// Allow up to 3x growth from baseline (accounts for lazy-loaded chunks)
if (baselineHeap !== null && finalHeap !== null) {
expect(
finalHeap,
`Heap grew from ${(baselineHeap / 1024 / 1024).toFixed(1)}MB to ${(finalHeap / 1024 / 1024).toFixed(1)}MB (${((finalHeap / baselineHeap) * 100).toFixed(0)}%)`,
).toBeLessThan(baselineHeap * 3);
}
});
test("20 dialog open/close cycles do not leak memory", async ({ loggedInPage: page }) => {
await page.waitForLoadState("networkidle");
const baselineHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
for (let i = 0; i < 20; i++) {
await openSettings(page);
await page.keyboard.press("Escape");
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible({
timeout: 5_000,
});
}
const finalHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
// Dialogs should not cause unbounded growth; allow 2x for GC timing
if (baselineHeap !== null && finalHeap !== null) {
expect(
finalHeap,
`Dialog cycles: heap grew from ${(baselineHeap / 1024 / 1024).toFixed(1)}MB to ${(finalHeap / 1024 / 1024).toFixed(1)}MB`,
).toBeLessThan(baselineHeap * 2);
}
// Page should remain responsive
await expect(page.locator("main")).toBeVisible();
});
test("10 upload/clear cycles do not leak memory", async ({ loggedInPage: page }) => {
await page.goto("/resize");
await page.waitForLoadState("networkidle");
const baselineHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
for (let i = 0; i < 10; i++) {
await uploadTestImage(page);
await expect(page.getByText(/test-image/i).first()).toBeVisible({ timeout: 5_000 });
const clearBtn = page.getByText("Clear all");
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await clearBtn.click();
await page.waitForTimeout(300);
}
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
}
const finalHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
// Upload/clear cycles with blob URL cleanup should not leak significantly
if (baselineHeap !== null && finalHeap !== null) {
expect(
finalHeap,
`Upload/clear cycles: heap grew from ${(baselineHeap / 1024 / 1024).toFixed(1)}MB to ${(finalHeap / 1024 / 1024).toFixed(1)}MB`,
).toBeLessThan(baselineHeap * 2.5);
}
// No blob URLs should remain after clearing
const blobImages = page.locator("img[src^='blob:']");
await expect(blobImages).toHaveCount(0);
});
test("rapid 15-page navigation does not exceed memory budget", async ({ loggedInPage: page }) => {
const routes = [
"/resize",
"/crop",
"/rotate",
"/convert",
"/compress",
"/sharpening",
"/adjust-colors",
"/strip-metadata",
"/bulk-rename",
"/favicon",
"/watermark",
"/border",
"/flip",
"/qr-generate",
"/image-to-pdf",
];
// Warm up
await page.goto("/");
await page.waitForLoadState("networkidle");
const baselineHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
// Rapid navigation through all 15 pages
for (const route of routes) {
await page.goto(route);
await page.waitForLoadState("domcontentloaded");
// Each page should render content
const content = await page.textContent("body");
expect(content).toBeDefined();
expect(content?.length).toBeGreaterThan(0);
}
const finalHeap = await page.evaluate(() => {
const perf = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return perf.memory?.usedJSHeapSize ?? null;
});
// No JS errors during rapid navigation
expect(errors).toHaveLength(0);
// Memory should stay bounded (3x for all lazy chunks loading)
if (baselineHeap !== null && finalHeap !== null) {
expect(
finalHeap,
`Rapid 15-page nav: heap grew from ${(baselineHeap / 1024 / 1024).toFixed(1)}MB to ${(finalHeap / 1024 / 1024).toFixed(1)}MB`,
).toBeLessThan(baselineHeap * 3);
}
// Page should still be responsive at the end
await expect(page.locator("main")).toBeVisible();
await expect(page.locator("aside")).toBeVisible();
});
});