mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: testing overhaul -- CI e2e gates, parallel suites, generated matrices, mutation testing (#215)
Closes the "e2e never runs in CI" hole. Adds per-PR e2e smoke gate, nightly full-suite workflows, parallel vitest forks (per-fork DBs), Playwright parallel/serial/visual projects against production builds, metadata-generated test suites (drift guards, hostile inputs, format matrix, pairwise settings, property-based fuzz), Stryker mutation testing, Schemathesis API fuzz, coverage ratchet, and fixes for three session-poisoning bugs that caused 200+ serial-bucket failures. Bug fix included: favicon/split/bulk-rename could hang clients forever when ZIP streaming failed after reply.hijack().
This commit is contained in:
+65
-2
@@ -113,8 +113,14 @@ export async function uploadTestImage(page: Page): Promise<void> {
|
||||
const testImagePath = getTestImagePath();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const dropzone = page.locator("[class*='border-dashed']").first();
|
||||
await dropzone.click();
|
||||
// Prefer the explicit upload button; on some tool pages the first
|
||||
// border-dashed element is a settings section, not the dropzone.
|
||||
const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first();
|
||||
if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await uploadButton.click();
|
||||
} else {
|
||||
await page.locator("[class*='border-dashed']").first().click();
|
||||
}
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(testImagePath);
|
||||
|
||||
@@ -141,10 +147,60 @@ export async function waitForProcessing(page: Page, timeoutMs = 30_000) {
|
||||
// (all "chromium" project tests already have auth via storageState,
|
||||
// but this provides backward compatibility for tests that use it)
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// putSettings() — write system settings through the API using the page's
|
||||
// bearer token (the app stores it in localStorage, so page.request alone
|
||||
// sends no auth).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function getAuthToken(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem("snapotter-token")).catch(() => null);
|
||||
}
|
||||
|
||||
export async function putSettings(
|
||||
page: Page,
|
||||
data: Record<string, string>,
|
||||
): Promise<{ ok: boolean; status: number }> {
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.put("/api/v1/settings", {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
data,
|
||||
});
|
||||
return { ok: res.ok(), status: res.status() };
|
||||
}
|
||||
|
||||
/**
|
||||
* changePasswordViaApi() — revert a password change without driving the UI.
|
||||
* The current session token survives a password change (the API only revokes
|
||||
* other sessions), so tests that successfully change the admin password MUST
|
||||
* call this to restore it before finishing.
|
||||
*/
|
||||
export async function changePasswordViaApi(
|
||||
page: Page,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ ok: boolean; status: number }> {
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.post("/api/auth/change-password", {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
data: { currentPassword, newPassword },
|
||||
});
|
||||
return { ok: res.ok(), status: res.status() };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ loggedInPage: Page }>({
|
||||
loggedInPage: async ({ page }, use) => {
|
||||
// storageState is already loaded by the project config, just navigate
|
||||
await page.goto("/");
|
||||
// Self-heal global server settings a crashed predecessor may have left
|
||||
// mutated (e.g. defaultToolView=fullscreen redirects "/" and hides the
|
||||
// sidebar, cascading failures through every later test on the shared DB).
|
||||
const healed = await putSettings(page, { defaultToolView: "sidebar", defaultLocale: "en" });
|
||||
if (!healed.ok) {
|
||||
console.warn(`loggedInPage settings heal failed with status ${healed.status}`);
|
||||
}
|
||||
if (page.url().includes("/fullscreen")) {
|
||||
await page.goto("/");
|
||||
}
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
@@ -170,6 +226,13 @@ export async function isAiSidecarRunning(page: Page): Promise<boolean> {
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function openSettings(page: Page): Promise<void> {
|
||||
const sidebar = page.locator("aside");
|
||||
if (!(await sidebar.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
// Fullscreen grid layout hides the aside behind a banner "Sidebar" toggle.
|
||||
const sidebarToggle = page.getByRole("button", { name: /^sidebar$/i });
|
||||
if (await sidebarToggle.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await sidebarToggle.click();
|
||||
}
|
||||
}
|
||||
if (await sidebar.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await sidebar.getByText("Settings").click();
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user