mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Lands five integrated branches: pipeline templates (#355), analytics opt-out (#354), 83 conversion presets bringing the catalog to 240 tools (#356), self-hosted positioning (#353), and e2e modernization (#351). Integration fixes: aligned stale web analytics tests with the opt-out/allow-list model, closed 3 CodeQL incomplete-sanitization alerts in the i18n generator, resolved settings/index/docs/format-matrix conflicts, and corrected tool counts to 240.
256 lines
10 KiB
TypeScript
256 lines
10 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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 };
|