mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
174 lines
5.4 KiB
JavaScript
174 lines
5.4 KiB
JavaScript
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 };
|
||
|
|
}
|