import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; import { test as base, expect, type Page } from "@playwright/test"; // --------------------------------------------------------------------------- // login() — fill the login form and submit (for tests that need fresh login) // --------------------------------------------------------------------------- export async function login(page: Page, username = "admin", password = "admin") { await page.goto("/login"); await page.getByLabel("Username").fill(username); await page.getByLabel("Password").fill(password); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); } // --------------------------------------------------------------------------- // createTestImageFile() — create a small test PNG on disk and return its path // --------------------------------------------------------------------------- let _testImagePath: string | null = null; export function getTestImagePath(): string { if (_testImagePath && fs.existsSync(_testImagePath)) return _testImagePath; const dir = path.join(process.cwd(), "test-results"); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); _testImagePath = path.join(dir, "test-image.png"); // Re-use an existing file (e.g. pre-created before the test run) if (fs.existsSync(_testImagePath)) return _testImagePath; try { const script = [ "const sharp = require('sharp');", `sharp({create:{width:100,height:100,channels:4,background:{r:255,g:0,b:0,alpha:1}}}).png().toFile(${JSON.stringify(_testImagePath)})`, ].join(" "); execFileSync("node", ["-e", script], { cwd: process.cwd(), timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }); } catch { // Fallback: build a valid 100x100 RGBA PNG without sharp // zlib imported at top of file const width = 100; const height = 100; const raw = Buffer.alloc((1 + width * 4) * height); for (let y = 0; y < height; y++) { const off = y * (1 + width * 4); raw[off] = 0; // filter: none for (let x = 0; x < width; x++) { const px = off + 1 + x * 4; raw[px] = 255; // R raw[px + 3] = 255; // A } } const deflated = zlib.deflateSync(raw); const crc32 = (buf: Buffer) => { let c = 0xffffffff; const t = new Int32Array(256); for (let i = 0; i < 256; i++) { let v = i; for (let j = 0; j < 8; j++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1; t[i] = v; } for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; const chunk = (type: string, data: Buffer) => { const tb = Buffer.from(type); const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([tb, data]))); return Buffer.concat([len, tb, data, crcBuf]); }; const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // RGBA fs.writeFileSync( _testImagePath, Buffer.concat([ sig, chunk("IHDR", ihdr), chunk("IDAT", deflated), chunk("IEND", Buffer.alloc(0)), ]), ); } return _testImagePath; } // --------------------------------------------------------------------------- // getTestHeicPath() — return a small HEIC test image (from fixtures) // --------------------------------------------------------------------------- export function getTestHeicPath(): string { return path.join(process.cwd(), "tests", "fixtures", "image", "valid", "test-200x150.heic"); } // --------------------------------------------------------------------------- // uploadTestImage() — upload a test image via the file chooser on a tool page // --------------------------------------------------------------------------- export async function uploadTestImage(page: Page): Promise { const testImagePath = getTestImagePath(); const fileChooserPromise = page.waitForEvent("filechooser"); // 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); // Wait for React state to update await page.waitForTimeout(500); } // --------------------------------------------------------------------------- // mockAiFeaturesInstalled() — make AI-tool bundles report as installed. // // AI tools (erase-object, upscale, ...) render a FeatureInstallPrompt instead of // the tool UI when their bundle is missing, so their e2e specs otherwise skip in // CI and on any box without the bundle. Mock the feature-status endpoint so the // tool UI renders. Call BEFORE navigating to the tool page, then goto (a full // load resets the in-memory features store, which re-fetches through this mock). // These specs exercise client-side UI up to mask generation, not the real AI // backend / processing. // --------------------------------------------------------------------------- export async function mockAiFeaturesInstalled( page: Page, bundles: Array<{ id: string; enablesTools: string[]; name?: string }>, ): Promise { await page.route("**/api/v1/features", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ bundles: bundles.map((b) => ({ id: b.id, name: b.name ?? b.id, description: "", status: "installed", installedVersion: "1.0.0", estimatedSize: "", downloadBytes: null, installedBytes: null, enablesTools: b.enablesTools, progress: null, })), }), }), ); } // --------------------------------------------------------------------------- // waitForProcessing() — wait for processing to complete // --------------------------------------------------------------------------- export async function waitForProcessing(page: Page, timeoutMs = 30_000) { try { const spinner = page.locator("[class*='animate-spin']"); if (await spinner.isVisible({ timeout: 2000 })) { await spinner.waitFor({ state: "hidden", timeout: timeoutMs }); } } catch { // No spinner appeared — processing may have been instant } } // --------------------------------------------------------------------------- // Custom test fixture — loggedInPage uses the saved storageState // (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 { return page.evaluate(() => localStorage.getItem("snapotter-token")).catch(() => null); } export async function putSettings( page: Page, data: Record, ): 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. defaultToolView=fullscreen would redirect "/"; a stale locale // would translate the whole UI. loginAttemptLimit is the important one: // saving System Settings persists it at the UI default ("5"), which // overrides the env LOGIN_ATTEMPT_LIMIT=100000 and 429s every later admin // login, cascading into "create user 401" failures across the serial run. const healed = await putSettings(page, { defaultToolView: "sidebar", defaultLocale: "en", loginAttemptLimit: "100000", }); if (!healed.ok) { console.warn(`loggedInPage settings heal failed with status ${healed.status}`); } if (page.url().includes("/fullscreen")) { await page.goto("/"); } await use(page); }, }); // --------------------------------------------------------------------------- // isAiSidecarRunning() — check if the Python AI dispatcher is ready // --------------------------------------------------------------------------- export async function isAiSidecarRunning(page: Page): Promise { try { const response = await page.request.get("/api/v1/admin/health"); if (!response.ok()) return false; const health = (await response.json()) as { ai?: { dispatcher?: { ready?: boolean; running?: boolean } }; }; return health.ai?.dispatcher?.ready === true; } catch { return false; } } // --------------------------------------------------------------------------- // openSettings() — reliably open the Settings dialog across all viewports // --------------------------------------------------------------------------- export async function openSettings(page: Page): Promise { // 2.0 removed the desktop sidebar. Settings now opens from the mobile bottom // nav (below the 768px breakpoint) or the top-nav avatar (user) dropdown // above it. Branch on viewport width rather than probing element visibility: // WebKit can take longer than a short visibility timeout to paint the avatar, // which would otherwise misroute to the mobile path. The avatar button // carries data-testid="user-menu" so this works for any logged-in user. const width = page.viewportSize()?.width ?? 1280; if (width < 768) { // Mobile: Settings lives in the fixed bottom nav. await page .getByRole("button", { name: /settings/i }) .first() .click(); } else { await page.getByTestId("user-menu").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); } await page.getByRole("dialog").waitFor({ state: "visible", timeout: 5000 }); } export { expect };