2026-07-27 15:37:30 +08:00
|
|
|
import { randomBytes, randomInt } from "node:crypto";
|
2026-03-22 19:28:57 +08:00
|
|
|
import path from "node:path";
|
2026-03-25 09:27:12 +08:00
|
|
|
import { defineConfig, devices } from "@playwright/test";
|
2026-07-27 15:37:30 +08:00
|
|
|
import { resolvePlaywrightBackingState } from "./tests/playwright-backing-state.mjs";
|
2026-03-22 19:28:57 +08:00
|
|
|
|
2026-07-27 15:37:30 +08:00
|
|
|
function resolveRunId(): string {
|
|
|
|
|
const runId = process.env.PLAYWRIGHT_RUN_ID ?? `${process.pid}_${randomBytes(4).toString("hex")}`;
|
|
|
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runId)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`PLAYWRIGHT_RUN_ID must be 1-64 letters, digits, underscores, or hyphens and start with a letter or digit, received ${JSON.stringify(runId)}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
process.env.PLAYWRIGHT_RUN_ID = runId;
|
|
|
|
|
return runId;
|
|
|
|
|
}
|
2026-04-24 01:24:15 +08:00
|
|
|
|
2026-07-27 15:37:30 +08:00
|
|
|
const runId = resolveRunId();
|
|
|
|
|
const runRoot = path.join(__dirname, "test-results", "e2e-runs", runId);
|
|
|
|
|
const authFile = path.join(runRoot, "auth", "user.json");
|
|
|
|
|
const webDistDir = path.join(runRoot, "web-dist");
|
|
|
|
|
process.env.PLAYWRIGHT_RUN_ROOT = runRoot;
|
|
|
|
|
process.env.PLAYWRIGHT_AUTH_FILE = authFile;
|
|
|
|
|
|
|
|
|
|
type E2eEndpoint = {
|
|
|
|
|
host: string;
|
|
|
|
|
port: number;
|
|
|
|
|
url: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parsePort(value: string, envName: string): number {
|
|
|
|
|
if (!/^\d+$/.test(value)) {
|
|
|
|
|
throw new Error(`${envName} must be an integer port, received "${value}"`);
|
|
|
|
|
}
|
|
|
|
|
const port = Number(value);
|
|
|
|
|
if (!Number.isSafeInteger(port) || port < 1024 || port > 65_535) {
|
|
|
|
|
throw new Error(`${envName} must be between 1024 and 65535, received "${value}"`);
|
|
|
|
|
}
|
|
|
|
|
return port;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function randomPort(min: number): number {
|
|
|
|
|
return randomInt(min, min + 10_000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveEndpoint(kind: "API" | "WEB", defaultPortFloor: number): E2eEndpoint {
|
|
|
|
|
const portEnvName = `PLAYWRIGHT_${kind}_PORT`;
|
|
|
|
|
const urlEnvName = `PLAYWRIGHT_${kind}_URL`;
|
|
|
|
|
const urlOverride = process.env[urlEnvName] ?? (kind === "API" ? process.env.API_URL : undefined);
|
|
|
|
|
const parsedOverride = urlOverride ? new URL(urlOverride) : undefined;
|
|
|
|
|
const port = process.env[portEnvName]
|
|
|
|
|
? parsePort(process.env[portEnvName], portEnvName)
|
|
|
|
|
: parsedOverride?.port
|
|
|
|
|
? parsePort(parsedOverride.port, urlEnvName)
|
|
|
|
|
: randomPort(defaultPortFloor);
|
|
|
|
|
const parsedUrl = parsedOverride ?? new URL(`http://127.0.0.1:${port}`);
|
|
|
|
|
|
|
|
|
|
if (parsedUrl.protocol !== "http:") {
|
|
|
|
|
throw new Error(`${urlEnvName} must use http, received "${parsedUrl.protocol}"`);
|
|
|
|
|
}
|
|
|
|
|
if (!["127.0.0.1", "localhost"].includes(parsedUrl.hostname)) {
|
|
|
|
|
throw new Error(`${urlEnvName} must use a loopback host, received "${parsedUrl.hostname}"`);
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
parsedUrl.username ||
|
|
|
|
|
parsedUrl.password ||
|
|
|
|
|
parsedUrl.pathname !== "/" ||
|
|
|
|
|
parsedUrl.search ||
|
|
|
|
|
parsedUrl.hash
|
|
|
|
|
) {
|
|
|
|
|
throw new Error(`${urlEnvName} must be an origin without credentials, path, query, or hash`);
|
|
|
|
|
}
|
|
|
|
|
if (parsePort(parsedUrl.port || "80", urlEnvName) !== port) {
|
|
|
|
|
throw new Error(`${portEnvName} must match the port in ${urlEnvName}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = parsedUrl.origin;
|
|
|
|
|
process.env[portEnvName] = String(port);
|
|
|
|
|
process.env[urlEnvName] = url;
|
|
|
|
|
return { host: parsedUrl.hostname, port, url };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const apiEndpoint = resolveEndpoint("API", 20_000);
|
|
|
|
|
const webEndpoint = resolveEndpoint("WEB", 30_000);
|
|
|
|
|
const TEST_API_URL = apiEndpoint.url;
|
|
|
|
|
const TEST_WEB_URL = webEndpoint.url;
|
|
|
|
|
process.env.API_URL = TEST_API_URL;
|
2026-03-22 19:28:57 +08:00
|
|
|
|
2026-06-13 10:15:23 +08:00
|
|
|
const E2E_PG_BASE_URL =
|
|
|
|
|
process.env.E2E_PG_BASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter";
|
2026-07-27 15:37:30 +08:00
|
|
|
const backingState = resolvePlaywrightBackingState({
|
|
|
|
|
postgresBaseUrl: E2E_PG_BASE_URL,
|
|
|
|
|
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
|
|
|
|
runId,
|
|
|
|
|
scope: "main",
|
|
|
|
|
});
|
2026-06-13 10:15:23 +08:00
|
|
|
|
2026-06-10 22:01:13 +08:00
|
|
|
// Specs that mutate global server state (settings, users, roles, API keys)
|
|
|
|
|
// or assert on global lists/timing. These run in the chromium-serial project
|
|
|
|
|
// with --workers=1; everything else parallelizes safely.
|
|
|
|
|
const SERIAL_SPECS =
|
2026-07-18 11:36:08 +08:00
|
|
|
/gui-settings-|settings\.spec|rbac|security|people|api\.spec|state-bleed|full-session|gui-file-carry|library-save-mode|i18n|theme|gui-performance/;
|
2026-06-10 22:01:13 +08:00
|
|
|
|
|
|
|
|
// Screenshot-comparison specs. Separate project because baselines are
|
|
|
|
|
// platform-specific: they run locally (darwin baselines) and via the
|
|
|
|
|
// update-visual-baselines workflow, but not in the nightly linux run until
|
|
|
|
|
// linux baselines are committed.
|
2026-07-27 15:37:30 +08:00
|
|
|
const VISUAL_SPECS = /gui-visual-/;
|
|
|
|
|
const LEGACY_VISUAL_SPECS = /visual-regression\.spec\.ts/;
|
|
|
|
|
|
|
|
|
|
// Stable, engine-neutral coverage shared by Firefox and WebKit. Broader specs
|
|
|
|
|
// remain Chromium-owned when they rely on engine-specific browser behavior.
|
|
|
|
|
const CROSS_BROWSER_SPECS =
|
|
|
|
|
/(?:^|[/\\])(?:gui-cross-browser|smoke|navigation|home-page)\.spec\.ts$/;
|
|
|
|
|
|
|
|
|
|
// Exact CSS boundary and wide-screen ownership lives in one small project so
|
|
|
|
|
// these widths cannot disappear inside device presets or ad-hoc test overrides.
|
|
|
|
|
const WIDTH_SPECS = /viewport-widths\.spec\.ts/;
|
2026-06-10 22:01:13 +08:00
|
|
|
|
2026-06-20 02:15:50 +08:00
|
|
|
// Device-emulated specs (real touch, UA, DPR). Tagged @mobile or @tablet
|
|
|
|
|
// and routed to the dedicated device projects below.
|
2026-06-20 03:44:26 +08:00
|
|
|
const DEVICE_SPECS = /device-mobile|device-tablet|device-visual|device-a11y/;
|
2026-06-20 02:15:50 +08:00
|
|
|
|
2026-03-22 19:28:57 +08:00
|
|
|
export default defineConfig({
|
|
|
|
|
testDir: "./tests/e2e",
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
expect: {
|
|
|
|
|
timeout: 10_000,
|
2026-03-23 11:46:45 +08:00
|
|
|
toHaveScreenshot: {
|
|
|
|
|
maxDiffPixelRatio: 0.01,
|
|
|
|
|
animations: "disabled",
|
|
|
|
|
caret: "hide",
|
|
|
|
|
},
|
2026-03-22 19:28:57 +08:00
|
|
|
},
|
2026-06-10 22:01:13 +08:00
|
|
|
// Platform-suffixed baselines: darwin baselines serve local runs on macOS,
|
|
|
|
|
// linux baselines (generated by the update-visual-baselines workflow) serve CI.
|
|
|
|
|
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}-{platform}{ext}",
|
2026-03-22 19:28:57 +08:00
|
|
|
fullyParallel: false,
|
2026-06-10 22:01:13 +08:00
|
|
|
retries: process.env.CI ? 1 : 0,
|
|
|
|
|
// Files run across workers; tests within a file stay ordered. The serial
|
|
|
|
|
// bucket is pinned to --workers=1 by its run command. Default is 2: the
|
|
|
|
|
// dev-mode webServers saturate beyond that and 30s-timeout tests start
|
|
|
|
|
// flaking. Raise via PW_WORKERS on stronger setups.
|
|
|
|
|
workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : 2,
|
2026-07-27 15:37:30 +08:00
|
|
|
outputDir: path.join(runRoot, "playwright-output"),
|
|
|
|
|
reporter: [["html", { open: "never", outputFolder: path.join(runRoot, "playwright-report") }]],
|
2026-03-22 19:28:57 +08:00
|
|
|
use: {
|
2026-07-27 15:37:30 +08:00
|
|
|
baseURL: TEST_WEB_URL,
|
2026-03-22 19:28:57 +08:00
|
|
|
screenshot: "only-on-failure",
|
|
|
|
|
trace: "retain-on-failure",
|
|
|
|
|
},
|
|
|
|
|
projects: [
|
|
|
|
|
{
|
|
|
|
|
name: "setup",
|
2026-07-27 15:37:30 +08:00
|
|
|
testMatch: /(?:^|[/\\])auth\.setup\.ts$/,
|
2026-03-22 19:28:57 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chromium",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
2026-07-27 15:37:30 +08:00
|
|
|
testIgnore: [SERIAL_SPECS, VISUAL_SPECS, LEGACY_VISUAL_SPECS, DEVICE_SPECS, WIDTH_SPECS],
|
2026-06-10 22:01:13 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chromium-serial",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: SERIAL_SPECS,
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chromium-visual",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: VISUAL_SPECS,
|
2026-03-22 19:28:57 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
2026-07-27 15:37:30 +08:00
|
|
|
{
|
|
|
|
|
// The older broad screenshot matrix has no maintained platform baselines
|
|
|
|
|
// yet. Keep it explicitly runnable and collectible without silently
|
|
|
|
|
// skipping based on CI/DOCKER environment variables.
|
|
|
|
|
name: "chromium-legacy-visual",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: LEGACY_VISUAL_SPECS,
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
2026-05-01 04:17:19 +08:00
|
|
|
{
|
|
|
|
|
name: "firefox",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Firefox"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
2026-07-27 15:37:30 +08:00
|
|
|
testMatch: CROSS_BROWSER_SPECS,
|
2026-05-01 04:17:19 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "webkit",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Safari"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
2026-07-27 15:37:30 +08:00
|
|
|
testMatch: CROSS_BROWSER_SPECS,
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chromium-widths",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: WIDTH_SPECS,
|
2026-05-01 04:17:19 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
2026-06-20 02:15:50 +08:00
|
|
|
|
|
|
|
|
// ---- Real device-emulated projects (touch, UA, DPR, engine) ----
|
|
|
|
|
{
|
|
|
|
|
name: "mobile-chromium",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Pixel 7"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: DEVICE_SPECS,
|
|
|
|
|
grep: /@mobile/,
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "mobile-webkit",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["iPhone 14"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: DEVICE_SPECS,
|
|
|
|
|
grep: /@mobile/,
|
2026-06-28 18:57:53 +08:00
|
|
|
// @visual baselines are platform-suffixed but not browser-suffixed, so a
|
|
|
|
|
// single baseline cannot match both chromium and webkit rendering. Visual
|
|
|
|
|
// regression runs on the chromium device projects only; webkit covers the
|
|
|
|
|
// functional device flows.
|
|
|
|
|
grepInvert: /@visual/,
|
2026-06-20 02:15:50 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "tablet-webkit",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["iPad (gen 7)"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: DEVICE_SPECS,
|
|
|
|
|
grep: /@tablet/,
|
2026-06-28 18:57:53 +08:00
|
|
|
grepInvert: /@visual/,
|
2026-06-20 02:15:50 +08:00
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "tablet-chromium",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Galaxy Tab S9"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
testMatch: DEVICE_SPECS,
|
|
|
|
|
grep: /@tablet/,
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
2026-03-22 19:28:57 +08:00
|
|
|
],
|
|
|
|
|
webServer: [
|
|
|
|
|
{
|
2026-07-27 15:37:30 +08:00
|
|
|
command: `node tests/playwright-api-lifecycle.mjs ${runId} main -- pnpm --filter @snapotter/api exec tsx watch --import ./src/tracing.ts --import ./src/instrument.ts src/index.ts`,
|
|
|
|
|
url: `${TEST_API_URL}/api/v1/health`,
|
|
|
|
|
reuseExistingServer: false,
|
|
|
|
|
gracefulShutdown: { signal: "SIGTERM", timeout: 30_000 },
|
2026-03-22 19:28:57 +08:00
|
|
|
env: {
|
2026-07-27 15:37:30 +08:00
|
|
|
PORT: String(apiEndpoint.port),
|
2026-03-22 19:28:57 +08:00
|
|
|
AUTH_ENABLED: "true",
|
|
|
|
|
DEFAULT_USERNAME: "admin",
|
|
|
|
|
DEFAULT_PASSWORD: "admin",
|
2026-03-23 11:46:45 +08:00
|
|
|
RATE_LIMIT_PER_MIN: "50000",
|
2026-06-28 18:57:53 +08:00
|
|
|
// The login route has its own per-minute attempt cap (default 10). The
|
|
|
|
|
// RBAC/settings specs sign in many times in quick succession, so raise
|
|
|
|
|
// it well above any single run to avoid 429s that cascade into
|
|
|
|
|
// create-user 401s and login timeouts.
|
|
|
|
|
LOGIN_ATTEMPT_LIMIT: "100000",
|
2026-03-28 11:19:09 +08:00
|
|
|
SKIP_MUST_CHANGE_PASSWORD: "true",
|
2026-04-24 01:24:15 +08:00
|
|
|
ANALYTICS_ENABLED: "false",
|
2026-06-28 18:57:53 +08:00
|
|
|
// The api-keys management routes cap at 30/min per IP in production. The
|
|
|
|
|
// serial api-keys specs hit the list endpoint far more than that on a
|
|
|
|
|
// shared IP, so raise the cap well above any single run.
|
|
|
|
|
API_KEYS_RATE_LIMIT_PER_MIN: "100000",
|
2026-07-27 15:37:30 +08:00
|
|
|
DATABASE_URL: backingState.databaseUrl,
|
|
|
|
|
E2E_PG_BASE_URL: backingState.postgresBaseUrl,
|
|
|
|
|
REDIS_URL: backingState.redisUrl,
|
|
|
|
|
BULLMQ_PREFIX: backingState.bullmqPrefix,
|
2026-06-10 22:01:13 +08:00
|
|
|
// The in-repo docker/feature-manifest.json makes the API think it is
|
|
|
|
|
// inside Docker and try to mkdir /data; point it somewhere writable.
|
2026-07-27 15:37:30 +08:00
|
|
|
DATA_DIR: path.join(runRoot, "data"),
|
|
|
|
|
FILES_STORAGE_PATH: path.join(runRoot, "files"),
|
|
|
|
|
WORKSPACE_PATH: path.join(runRoot, "workspace"),
|
2026-06-28 18:57:53 +08:00
|
|
|
// Point feature-manifest detection at a path that does not exist so the
|
|
|
|
|
// API runs in native mode: it reports every AI bundle as available
|
|
|
|
|
// (apps/api/src/routes/features.ts) instead of gating each AI tool
|
|
|
|
|
// behind a multi-GB "requires an additional download" install prompt.
|
|
|
|
|
// The e2e env never downloads the model bundles, so without this every
|
|
|
|
|
// AI-tool GUI test is blocked before reaching a control. Real inference
|
|
|
|
|
// still no-ops, but the AI GUI specs assert on settings only and the
|
|
|
|
|
// processing specs skip when the sidecar is absent.
|
2026-07-27 15:37:30 +08:00
|
|
|
FEATURE_MANIFEST_PATH: path.join(runRoot, ".no-feature-manifest.json"),
|
2026-03-22 19:28:57 +08:00
|
|
|
},
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-06-10 22:01:13 +08:00
|
|
|
// Production build + static preview: the dev server's on-demand
|
|
|
|
|
// transform saturates under parallel workers and flakes 30s-timeout
|
|
|
|
|
// tests. The build adds ~40s once per run and removes that whole class.
|
2026-07-27 15:37:30 +08:00
|
|
|
//
|
|
|
|
|
// The build output is run-scoped like every other mutable path here. The
|
|
|
|
|
// default apps/web/dist is shared, and vite empties the output directory
|
|
|
|
|
// before writing it, so a second run of this config deletes the assets
|
|
|
|
|
// the first run's preview server is still serving. The first run then
|
|
|
|
|
// fails with "Failed to fetch dynamically imported module" and lands on
|
|
|
|
|
// the error boundary, which reads as a product defect anywhere the chunk
|
|
|
|
|
// happened to load during that window.
|
|
|
|
|
// The build runs as its two steps rather than the package's build script
|
|
|
|
|
// because pnpm does not forward --outDir into a compound npm script.
|
|
|
|
|
command: `pnpm --filter @snapotter/web exec tsc -b && pnpm --filter @snapotter/web exec vite build --outDir ${webDistDir} --emptyOutDir && pnpm --filter @snapotter/web exec vite preview --outDir ${webDistDir} --host ${webEndpoint.host} --port ${webEndpoint.port} --strictPort`,
|
|
|
|
|
url: TEST_WEB_URL,
|
|
|
|
|
reuseExistingServer: false,
|
2026-04-24 01:24:15 +08:00
|
|
|
env: {
|
2026-07-27 15:37:30 +08:00
|
|
|
PORT: String(webEndpoint.port),
|
|
|
|
|
VITE_API_URL: TEST_API_URL,
|
2026-04-24 01:24:15 +08:00
|
|
|
},
|
2026-06-10 22:01:13 +08:00
|
|
|
timeout: 240_000,
|
2026-03-22 19:28:57 +08:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export { authFile };
|