2026-07-27 15:37:30 +08:00
|
|
|
import { randomBytes, randomInt } from "node:crypto";
|
2026-04-29 23:47:19 +08:00
|
|
|
import path from "node:path";
|
|
|
|
|
import { defineConfig, devices } from "@playwright/test";
|
2026-07-27 15:37:30 +08:00
|
|
|
import { resolvePlaywrightBackingState } from "./tests/playwright-backing-state.mjs";
|
2026-04-29 23:47:19 +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-29 23:47:19 +08:00
|
|
|
|
2026-07-27 15:37:30 +08:00
|
|
|
type E2eEndpoint = {
|
|
|
|
|
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 { port, url };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runId = resolveRunId();
|
|
|
|
|
const runRoot = path.join(__dirname, "test-results", "e2e-analytics-runs", runId);
|
|
|
|
|
const authFile = path.join(runRoot, "auth", "analytics-local-user.json");
|
|
|
|
|
process.env.PLAYWRIGHT_RUN_ROOT = runRoot;
|
|
|
|
|
process.env.PLAYWRIGHT_AUTH_FILE = authFile;
|
|
|
|
|
|
|
|
|
|
const apiEndpoint = resolveEndpoint("API", 20_000);
|
|
|
|
|
const webEndpoint = resolveEndpoint("WEB", 30_000);
|
|
|
|
|
process.env.API_URL = apiEndpoint.url;
|
2026-04-29 23:47:19 +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: "analytics-local",
|
|
|
|
|
});
|
2026-06-13 10:15:23 +08:00
|
|
|
|
2026-04-29 23:47:19 +08:00
|
|
|
export default defineConfig({
|
|
|
|
|
testDir: "./tests/e2e-analytics",
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
expect: { timeout: 10_000 },
|
|
|
|
|
fullyParallel: false,
|
|
|
|
|
retries: 0,
|
|
|
|
|
workers: 1,
|
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-04-29 23:47:19 +08:00
|
|
|
use: {
|
2026-07-27 15:37:30 +08:00
|
|
|
baseURL: webEndpoint.url,
|
2026-04-29 23:47:19 +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-04-29 23:47:19 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chromium",
|
|
|
|
|
use: {
|
|
|
|
|
...devices["Desktop Chrome"],
|
|
|
|
|
storageState: authFile,
|
|
|
|
|
},
|
|
|
|
|
dependencies: ["setup"],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
webServer: [
|
|
|
|
|
{
|
2026-06-28 18:57:53 +08:00
|
|
|
// NOTE: use the `start` script, not `dev`. The `dev` script hard-codes
|
2026-07-27 15:37:30 +08:00
|
|
|
// `PORT=13490`, which would override the isolated API endpoint below and
|
2026-06-28 18:57:53 +08:00
|
|
|
// collide with a developer's running dev server. `start` reads PORT from
|
|
|
|
|
// the env, so the PORT set in this webServer.env block is honored.
|
2026-07-27 15:37:30 +08:00
|
|
|
command: `node tests/playwright-api-lifecycle.mjs ${runId} analytics-local -- pnpm --filter @snapotter/api start`,
|
|
|
|
|
url: `${apiEndpoint.url}/api/v1/health`,
|
|
|
|
|
reuseExistingServer: false,
|
|
|
|
|
gracefulShutdown: { signal: "SIGTERM", timeout: 30_000 },
|
2026-04-29 23:47:19 +08:00
|
|
|
env: {
|
|
|
|
|
AUTH_ENABLED: "true",
|
|
|
|
|
DEFAULT_USERNAME: "admin",
|
|
|
|
|
DEFAULT_PASSWORD: "admin",
|
|
|
|
|
RATE_LIMIT_PER_MIN: "50000",
|
|
|
|
|
SKIP_MUST_CHANGE_PASSWORD: "true",
|
|
|
|
|
ANALYTICS_ENABLED: "true",
|
2026-06-28 18:57:53 +08:00
|
|
|
// Force the compile-time bake ON (dev/test only) so the effective
|
|
|
|
|
// analytics state is enabled by default and the opt-out toggle can be
|
|
|
|
|
// exercised end to end. bakedEnabled() honors this when
|
|
|
|
|
// NODE_ENV !== "production".
|
|
|
|
|
ANALYTICS_BAKED_OVERRIDE: "on",
|
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,
|
|
|
|
|
PORT: String(apiEndpoint.port),
|
2026-04-29 23:47:19 +08:00
|
|
|
},
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
command: "pnpm --filter @snapotter/web dev",
|
2026-07-27 15:37:30 +08:00
|
|
|
url: webEndpoint.url,
|
|
|
|
|
reuseExistingServer: false,
|
2026-04-29 23:47:19 +08:00
|
|
|
env: {
|
2026-07-27 15:37:30 +08:00
|
|
|
PORT: String(webEndpoint.port),
|
|
|
|
|
VITE_API_URL: apiEndpoint.url,
|
2026-04-29 23:47:19 +08:00
|
|
|
},
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export { authFile };
|