Files
SnapOtter/tests/e2e/helpers.ts
T
SnapOtterandGitHub 601557edae feat(erase-object): add freeform lasso selection mode (#503)
Adds a Brush | Lasso toggle to the object eraser. Lasso lets the user drag a freeform loop that auto-closes and fills into the mask, so they select around a subject instead of painting every pixel. Frontend-only; the mask contract is unchanged. Also un-skips the erase-object e2e suite via a shared mockAiFeaturesInstalled helper (7 tests now run; 2 multi-file tests fixme'd for a pre-existing tool-page remount bug). Closes #492.
2026-07-11 22:27:51 +08:00

293 lines
12 KiB
TypeScript

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<void> {
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<void> {
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<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. 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<boolean> {
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<void> {
// 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 };