Files

174 lines
5.4 KiB
JavaScript
Raw Permalink Normal View History

import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import path from "node:path";
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const SCOPE_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
const DATABASE_NAME_PATTERN = /^snapotter_e2e_[a-z0-9_]+_[a-f0-9]{24}$/;
const REDIS_DELETE_BATCH_SIZE = 500;
export const PLAYWRIGHT_BACKING_STATE_POOLS = ["image", "media", "ai", "docs", "system"];
const projectRoot = process.cwd();
const apiRequire = createRequire(path.join(projectRoot, "apps/api/package.json"));
function parseServiceUrl(value, label, protocols) {
let url;
try {
url = new URL(value);
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (!protocols.includes(url.protocol)) {
throw new Error(`${label} must use ${protocols.join(" or ")}`);
}
return url;
}
function expectedDatabaseName(runId, scope) {
const scopePart = scope.replaceAll("-", "_");
const digest = createHash("sha256").update(`${scope}\0${runId}`).digest("hex").slice(0, 24);
return `snapotter_e2e_${scopePart}_${digest}`;
}
export function resolvePlaywrightBackingState({ postgresBaseUrl, redisUrl, runId, scope }) {
if (!RUN_ID_PATTERN.test(runId)) {
throw new Error(
"Playwright run id must be 1-64 letters, digits, underscores, or hyphens and start with a letter or digit",
);
}
if (!SCOPE_PATTERN.test(scope)) {
throw new Error(
"Playwright backing-state scope must be 1-32 lowercase letters, digits, or hyphens",
);
}
const postgresUrl = parseServiceUrl(postgresBaseUrl, "E2E_PG_BASE_URL", [
"postgres:",
"postgresql:",
]);
parseServiceUrl(redisUrl, "REDIS_URL", ["redis:", "rediss:"]);
const databaseName = expectedDatabaseName(runId, scope);
if (!DATABASE_NAME_PATTERN.test(databaseName) || Buffer.byteLength(databaseName, "utf8") > 63) {
throw new Error("Derived Playwright database name is unsafe");
}
if (decodeURIComponent(postgresUrl.pathname.slice(1)) === databaseName) {
throw new Error("E2E_PG_BASE_URL must not connect to the run-owned database");
}
const databaseUrl = new URL(postgresUrl);
databaseUrl.pathname = `/${databaseName}`;
return Object.freeze({
bullmqPrefix: databaseName,
databaseName,
databaseUrl: databaseUrl.toString(),
postgresBaseUrl: postgresUrl.toString(),
redisUrl,
runId,
scope,
});
}
function assertPlaywrightBackingState(state) {
const expected = resolvePlaywrightBackingState({
postgresBaseUrl: state.postgresBaseUrl,
redisUrl: state.redisUrl,
runId: state.runId,
scope: state.scope,
});
for (const field of ["databaseName", "databaseUrl", "bullmqPrefix"]) {
if (state[field] !== expected[field]) {
throw new Error(
`Playwright backing-state ${field} does not match the validated run identity`,
);
}
}
return expected;
}
function ownsValidatedPlaywrightRedisKey(state, key) {
if (key.startsWith(`${state.bullmqPrefix}:`)) return true;
return PLAYWRIGHT_BACKING_STATE_POOLS.some((pool) =>
key.startsWith(`bull:${state.bullmqPrefix}-${pool}:`),
);
}
export function ownsPlaywrightRedisKey(state, key) {
const validated = assertPlaywrightBackingState(state);
return ownsValidatedPlaywrightRedisKey(validated, key);
}
async function dropExactDatabase(state) {
const pg = apiRequire("pg");
const client = new pg.Client({ connectionString: state.postgresBaseUrl });
await client.connect();
try {
await client.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[state.databaseName],
);
await client.query(`DROP DATABASE IF EXISTS "${state.databaseName}"`);
} finally {
await client.end();
}
}
async function deleteExactRedisKeys(state, ownsKey) {
const RedisModule = apiRequire("ioredis");
const Redis = RedisModule.default ?? RedisModule;
const redis = new Redis(state.redisUrl, {
enableReadyCheck: true,
maxRetriesPerRequest: 1,
});
const ownedKeys = new Set();
try {
let cursor = "0";
do {
const [nextCursor, keys] = await redis.scan(cursor, "COUNT", 500);
cursor = nextCursor;
for (const key of keys) {
if (ownsKey(key)) ownedKeys.add(key);
}
} while (cursor !== "0");
const keys = [...ownedKeys];
for (let offset = 0; offset < keys.length; offset += REDIS_DELETE_BATCH_SIZE) {
await redis.unlink(...keys.slice(offset, offset + REDIS_DELETE_BATCH_SIZE));
}
return keys.length;
} finally {
try {
await redis.quit();
} catch {
redis.disconnect();
}
}
}
const systemDriver = {
deleteRedisKeys: deleteExactRedisKeys,
dropDatabase: dropExactDatabase,
};
export async function teardownPlaywrightBackingState(state, driver = systemDriver) {
const validated = assertPlaywrightBackingState(state);
const ownsKey = (key) => ownsValidatedPlaywrightRedisKey(validated, key);
const [databaseResult, redisResult] = await Promise.allSettled([
driver.dropDatabase(validated),
driver.deleteRedisKeys(validated, ownsKey),
]);
const errors = [databaseResult, redisResult]
.filter((result) => result.status === "rejected")
.map((result) => result.reason);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, "Playwright backing-state teardown failed");
}
return { deletedRedisKeys: redisResult.value };
}