Files

165 lines
5.6 KiB
TypeScript
Raw Permalink Normal View History

import { randomBytes, randomInt } from "node:crypto";
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
import { resolvePlaywrightBackingState } from "./tests/playwright-backing-state.mjs";
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;
}
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;
const E2E_PG_BASE_URL =
process.env.E2E_PG_BASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter";
const backingState = resolvePlaywrightBackingState({
postgresBaseUrl: E2E_PG_BASE_URL,
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
runId,
scope: "analytics-local",
});
export default defineConfig({
testDir: "./tests/e2e-analytics",
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: 0,
workers: 1,
outputDir: path.join(runRoot, "playwright-output"),
reporter: [["html", { open: "never", outputFolder: path.join(runRoot, "playwright-report") }]],
use: {
baseURL: webEndpoint.url,
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "setup",
testMatch: /(?:^|[/\\])auth\.setup\.ts$/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: authFile,
},
dependencies: ["setup"],
},
],
webServer: [
{
// NOTE: use the `start` script, not `dev`. The `dev` script hard-codes
// `PORT=13490`, which would override the isolated API endpoint below and
// 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.
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 },
env: {
AUTH_ENABLED: "true",
DEFAULT_USERNAME: "admin",
DEFAULT_PASSWORD: "admin",
RATE_LIMIT_PER_MIN: "50000",
SKIP_MUST_CHANGE_PASSWORD: "true",
ANALYTICS_ENABLED: "true",
// 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",
DATABASE_URL: backingState.databaseUrl,
E2E_PG_BASE_URL: backingState.postgresBaseUrl,
REDIS_URL: backingState.redisUrl,
BULLMQ_PREFIX: backingState.bullmqPrefix,
PORT: String(apiEndpoint.port),
},
timeout: 30_000,
},
{
command: "pnpm --filter @snapotter/web dev",
url: webEndpoint.url,
reuseExistingServer: false,
env: {
PORT: String(webEndpoint.port),
VITE_API_URL: apiEndpoint.url,
},
timeout: 30_000,
},
],
});
export { authFile };