chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,78 @@
# Test containers
Vitest utilities for writing tests against real Postgres, Prisma, Redis and ClickHouse - we don't mock
(see the root `CLAUDE.md`), we boot containers. Also exposes a duration-weighted shard sequencer for
splitting slow suites across CI shards.
## Choosing a fixture
Most tests share one set of containers per vitest worker (booted once, reset between tests) - this is
much faster than a container per test. Reach for an isolated variant only when a test needs it.
| Fixture | Postgres | Redis | ClickHouse | Use for |
| -------------------------------- | -------------- | -------- | ---------- | --------------------------------------- |
| `redisTest` | - | shared | - | redis-only tests |
| `postgresTest` | shared (clone) | - | - | db-only tests |
| `containerTest` | shared (clone) | shared | shared | the default - needs all three |
| `isolatedRedisTest` | - | per-test | - | background redis work (see below) |
| `containerTestWithIsolatedRedis` | shared (clone) | per-test | shared | background redis work + db/clickhouse |
| `replicationContainerTest` | per-test | per-test | shared | Postgres→ClickHouse logical replication |
"shared (clone)" = one Postgres per worker with a template database; each test gets a fast `CREATE
DATABASE ... TEMPLATE` clone, so schema isn't re-pushed per test.
### The background-work gotcha
If a test spawns work that **outlives the test body** - a `RunEngine`, a `redis-worker` Worker, a
`BatchQueue` - and that work isn't fully drained before the test ends, you **must** use an isolated
redis fixture (`isolatedRedisTest` / `containerTestWithIsolatedRedis`).
On the shared fixture, the leaked background loop keeps polling the one worker-scoped redis after the
test's clients close, bleeding into the next test. The symptom is an intermittent `"Connection is
closed"` error or a test that hangs until its timeout. `FLUSHALL` between tests does **not** fix this -
it clears data, not live connections/loops, so per-test key prefixes won't help either. A plain
db/redis test with no lingering background work is fine on the shared fixtures.
## Sharding (`./sequencer`)
CI splits the slow suites with `vitest --shard=i/N`. `DurationShardingSequencer` replaces vitest's
default file-count split with a duration-weighted one: it reads `test-timings.json` at the repo root
(`{ "<repo-relative path>": <ms> }`) and greedily bin-packs files so each shard does roughly equal
_work_, not an equal _number of files_. The packing is deterministic, so every shard computes the same
bins and runs each file exactly once.
Configs opt in via:
```ts
import { DurationShardingSequencer } from "@internal/testcontainers/sequencer";
// in defineConfig:
test: {
sequence: {
sequencer: DurationShardingSequencer,
},
}
```
### Adding tests - nothing to do
New test files are discovered by vitest's glob and sharded automatically. A file with no entry in
`test-timings.json` is given the **median** duration as a fallback, so it's still placed on exactly one
shard - correctness never depends on the timings being present or current.
What the timings affect is **balance**. A new heavy test estimated at the median can be under-weighted
and land on an already-full shard, making that shard slower. There's headroom between the current
makespan and the CI budget to absorb this, so it tolerates drift - but if a shard creeps toward the
budget, refresh the timings.
### Refreshing `test-timings.json`
Measure each shard with the JSON reporter and write per-file `endTime - startTime` (ms), keyed by
repo-relative path, back into `test-timings.json`. Set `GITHUB_ACTIONS=true` so suites that
`skipIf(CI)` are excluded, matching what actually runs on CI:
```bash
GITHUB_ACTIONS=true pnpm exec vitest run --reporter=json --outputFile=/tmp/run.json
```
Stale entries for deleted/renamed files are harmless (they're simply ignored). This is a periodic
chore, not a per-PR one.
@@ -0,0 +1,60 @@
# Fast local testing loop
These tests use real Docker containers (Postgres, ClickHouse, Redis, Electric, MinIO) via testcontainers - never mocks. This guide is the fast inner loop for working on them.
## Prerequisites
- **Docker daemon running.** That's it - testcontainers boots its own containers. You do **not** need `pnpm run docker` (that compose stack is for running the app, and is separate).
## The loop
```bash
# 1. Build upstream deps once (turbo-caches them; only re-runs when a dep changes)
pnpm run build --filter @internal/run-engine
# 2. Iterate by running vitest DIRECTLY in the package - not via `turbo run test`
cd internal-packages/run-engine
pnpm exec vitest run src/engine/tests/ttl.test.ts # one file
pnpm exec vitest src/engine/tests/ttl.test.ts # watch mode, tightest loop
pnpm exec vitest run src/engine/tests/ --reporter=verbose # per-test timings
```
> **Why run vitest directly, not `turbo run test`?** The `test` turbo task is cacheable
> (`outputs: []`). A second `turbo run test` with no input change replays the cached
> result in ~0ms instead of executing - useless when you're measuring timing. Run vitest
> directly (or `turbo run test --force`) so tests actually run.
## Measuring container boot/teardown vs test time
Container lifecycle (boot + migrate + teardown) dominates these suites. To see the split:
```bash
# JSON timing lines are gated on TESTCONTAINERS_TIMING locally (always on in CI),
# and need --disableConsoleIntercept so vitest doesn't swallow them.
TESTCONTAINERS_TIMING=1 pnpm exec vitest run <file> --disableConsoleIntercept
```
## Approximating the 2-core CI runner locally (flake repro)
To reproduce CI-like CPU pressure on a beefy local machine - useful when a test only flakes under
the 2-core CI runner:
```bash
# cap each testcontainer's CPU/mem (TESTCONTAINERS_CPU = cores, TESTCONTAINERS_MEMORY_GB = GB),
# and pin the test runner to 2 cores. Off unless the env vars are set.
TESTCONTAINERS_CPU=2 TESTCONTAINERS_MEMORY_GB=2 taskset -c 0,1 pnpm exec vitest run <file>
```
Note: in practice the scoped tests here are latency/IO/sleep-bound, not CPU-bound, so this changes
timings little - the original CI slowness was per-test container _boots_, which worker-scoping removed.
Keep it for the cases that genuinely starve on CPU (e.g. timing races against a worker poll).
## Timing harness
Or use the harness, which aggregates the split for you:
```bash
node internal-packages/testcontainers/scripts/measure-test-timing.mjs \
src/client/client.test.ts --cwd internal-packages/clickhouse --runs 3
# -> run 1/3 passed=true wall=10.58s teardown=0.67s ...
```
@@ -0,0 +1,33 @@
{
"name": "@internal/testcontainers",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./webapp": "./src/webapp.ts",
"./sequencer": {
"types": "./src/sequencer.d.cts",
"default": "./src/sequencer.cjs"
}
},
"dependencies": {
"@clickhouse/client": "^1.11.1",
"@opentelemetry/api": "^1.9.1",
"@trigger.dev/database": "workspace:*",
"ioredis": "~5.6.0"
},
"devDependencies": {
"@internal/run-ops-database": "workspace:*",
"@testcontainers/postgresql": "^11.14.0",
"@testcontainers/redis": "^11.14.0",
"@trigger.dev/core": "workspace:*",
"std-env": "^3.9.0",
"testcontainers": "^11.14.0",
"tinyexec": "^0.3.0"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// Measure testcontainers boot/teardown vs test time for a single test file.
//
// Usage (from any package dir, or pass --cwd):
// node <path>/measure-test-timing.mjs <testFile> [--cwd <packageDir>] [--runs N]
//
// Relies on the TESTCONTAINERS_TIMING log gate in src/logs.ts and runs vitest with
// --disableConsoleIntercept so the JSON timing lines reach stdout.
import { spawn } from "node:child_process";
const args = process.argv.slice(2);
const testFile = args.find((a) => !a.startsWith("--"));
const cwd = valueOf("--cwd") ?? process.cwd();
const runs = Number(valueOf("--runs") ?? "1");
function valueOf(flag) {
const i = args.indexOf(flag);
return i >= 0 ? args[i + 1] : undefined;
}
if (!testFile) {
console.error("usage: measure-test-timing.mjs <testFile> [--cwd dir] [--runs N]");
process.exit(1);
}
function runOnce() {
return new Promise((resolve) => {
const child = spawn("pnpm", ["exec", "vitest", "run", testFile, "--disableConsoleIntercept"], {
cwd,
env: { ...process.env, TESTCONTAINERS_TIMING: "1" },
});
let out = "";
const collect = (buf) => (out += buf.toString());
child.stdout.on("data", collect);
child.stderr.on("data", collect);
child.on("close", () => {
const cleanups = [];
let duration = null;
for (const line of out.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("{")) {
try {
const ev = JSON.parse(trimmed);
if (ev.type === "cleanup") cleanups.push(ev);
} catch {}
}
const m = trimmed.match(/Duration\s+([\d.]+)s/);
if (m) duration = Number(m[1]);
}
resolve({ cleanups, duration, passed: /Tests\s+\d+ passed/.test(out) });
});
});
}
for (let i = 0; i < runs; i++) {
const { cleanups, duration, passed } = await runOnce();
const byResource = {};
for (const c of cleanups) {
const key = c.resource.split(":")[0];
byResource[key] ??= { totalMs: 0, count: 0 };
byResource[key].totalMs += c.durationMs ?? 0;
byResource[key].count += 1;
}
const teardownMs = Object.values(byResource).reduce((a, r) => a + r.totalMs, 0);
console.log(
`\nrun ${i + 1}/${runs} passed=${passed} wall=${duration}s teardown=${(
teardownMs / 1000
).toFixed(2)}s`
);
for (const [res, r] of Object.entries(byResource)) {
console.log(` teardown ${res}: ${(r.totalMs / 1000).toFixed(2)}s over ${r.count}`);
}
}
@@ -0,0 +1,201 @@
import type { ClickHouseClient } from "@clickhouse/client";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { StartedTestContainer } from "testcontainers";
import { AbstractStartedContainer, GenericContainer, Wait } from "testcontainers";
const CLICKHOUSE_PORT = 9000;
const CLICKHOUSE_HTTP_PORT = 8123;
export class ClickHouseContainer extends GenericContainer {
private username = "test";
private password = "test";
private database = "test";
constructor(
image = "clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251"
) {
super(image);
this.withExposedPorts(CLICKHOUSE_PORT, CLICKHOUSE_HTTP_PORT);
this.withWaitStrategy(
Wait.forHttp("/", CLICKHOUSE_HTTP_PORT).forResponsePredicate(
(response) => response === "Ok.\n"
)
);
this.withStartupTimeout(120_000);
// Setting this high ulimits value proactively prevents the "Too many open files" error,
// especially under potentially heavy load during testing.
this.withUlimits({
nofile: {
hard: 262144,
soft: 262144,
},
});
}
public withDatabase(database: string): this {
this.database = database;
return this;
}
public withUsername(username: string): this {
this.username = username;
return this;
}
public withPassword(password: string): this {
this.password = password;
return this;
}
public override async start(): Promise<StartedClickHouseContainer> {
this.withEnvironment({
CLICKHOUSE_USER: this.username,
CLICKHOUSE_PASSWORD: this.password,
CLICKHOUSE_DB: this.database,
});
return new StartedClickHouseContainer(
await super.start(),
this.database,
this.username,
this.password
);
}
}
export class StartedClickHouseContainer extends AbstractStartedContainer {
constructor(
startedTestContainer: StartedTestContainer,
private readonly database: string,
private readonly username: string,
private readonly password: string
) {
super(startedTestContainer);
}
public getPort(): number {
return super.getMappedPort(CLICKHOUSE_PORT);
}
public getHttpPort(): number {
return super.getMappedPort(CLICKHOUSE_HTTP_PORT);
}
public getUsername(): string {
return this.username;
}
public getPassword(): string {
return this.password;
}
public getDatabase(): string {
return this.database;
}
/**
* Gets the base HTTP URL (protocol, host and mapped port) for the ClickHouse container's HTTP interface.
* Example: `http://localhost:32768`
*/
public getHttpUrl(): string {
const protocol = "http";
const host = this.getHost();
const port = this.getHttpPort();
return `${protocol}://${host}:${port}`;
}
/**
* Gets configuration options suitable for passing directly to `createClient({...})`
* from `@clickhouse/client`. Uses the HTTP interface.
*/
public getClientOptions(): {
url?: string;
username: string;
password: string;
database: string;
} {
return {
url: this.getHttpUrl(),
username: this.getUsername(),
password: this.getPassword(),
database: this.getDatabase(),
};
}
/**
* Gets a ClickHouse connection URL for the HTTP interface with format:
* http://username:password@hostname:port/database
* @returns The ClickHouse HTTP URL string.
*/
public getConnectionUrl(): string {
const url = new URL(this.getHttpUrl());
url.username = this.getUsername();
url.password = this.getPassword();
const dbName = this.getDatabase();
url.pathname = dbName.startsWith("/") ? dbName : `/${dbName}`;
return url.toString();
}
}
/**
* Resets data between tests on a reused ClickHouse container by truncating every base table
* (MergeTree etc.) in the migrated database. Views/materialized views are skipped - their target
* tables are base tables and get truncated too, which clears MV state. Cheaper than dropping +
* re-migrating, and these migrations aren't version-tracked so they can't simply be re-run.
*/
export async function truncateClickhouseTables(client: ClickHouseClient, database = "trigger_dev") {
const result = await client.query({
query: `SELECT name FROM system.tables WHERE database = '${database}' AND engine NOT LIKE '%View%'`,
format: "JSONEachRow",
});
const tables = await result.json<{ name: string }>();
for (const { name } of tables) {
await client.command({ query: `TRUNCATE TABLE \`${database}\`.\`${name}\`` });
}
}
export async function runClickhouseMigrations(client: ClickHouseClient, migrationsPath: string) {
// Get all the *.sql files in the migrations path
const queries = await getAllClickhouseMigrationQueries(migrationsPath);
for (const query of queries) {
await client.command({
query,
});
}
}
async function getAllClickhouseMigrationQueries(migrationsPath: string) {
const queries: string[] = [];
// Get all the *.sql files in the migrations path
const migrations = await readdir(migrationsPath);
for (const migration of migrations) {
const migrationPath = resolve(migrationsPath, migration);
const migrationContent = await readFile(migrationPath, "utf-8");
// Split content by goose markers
const parts = migrationContent.split(/--\s*\+goose\s+(Up|Down)/i);
// The array will be: ["", "Up", "up queries", "Down", "down queries"]
// We want the "up queries" part which is at index 2
if (parts.length >= 3) {
const upQueries = parts[2]!.trim();
queries.push(
...upQueries
.split(";")
.filter((q) => q.trim())
.map((q) => q.trim())
);
}
}
return queries;
}
@@ -0,0 +1,160 @@
import { x } from "tinyexec";
function stringToLines(str: string): string[] {
return str.split("\n").filter(Boolean);
}
function lineToWords(line: string): string[] {
return line.trim().split(/\s+/);
}
async function getDockerNetworks(): Promise<string[]> {
try {
const result = await x("docker", ["network", "ls" /* , "--no-trunc" */]);
return stringToLines(result.stdout);
} catch (error) {
console.error(error);
return ["error: check additional logs for more details"];
}
}
async function getDockerContainers(): Promise<string[]> {
try {
const result = await x("docker", ["ps", "-a" /* , "--no-trunc" */]);
return stringToLines(result.stdout);
} catch (error) {
console.error(error);
return ["error: check additional logs for more details"];
}
}
type DockerResource = { id: string; name: string };
type DockerNetworkAttachment = DockerResource & {
containers: string[];
};
export async function getDockerNetworkAttachments(): Promise<DockerNetworkAttachment[]> {
let attachments: DockerNetworkAttachment[] = [];
let networks: DockerResource[] = [];
try {
const result = await x("docker", [
"network",
"ls",
"--format",
'{{.ID | printf "%.12s"}} {{.Name}}',
]);
const lines = stringToLines(result.stdout);
for (const line of lines) {
const [id, name] = lineToWords(line);
if (!id || !name) {
continue;
}
networks.push({ id, name });
}
} catch (err) {
console.error("Failed to list docker networks:", err);
}
for (const { id, name } of networks) {
try {
// Get containers, one per line: id name\n
const containersResult = await x("docker", [
"network",
"inspect",
"--format",
'{{range $k, $v := .Containers}}{{$k | printf "%.12s"}} {{$v.Name}}\n{{end}}',
id,
]);
const containers = stringToLines(containersResult.stdout);
attachments.push({ id, name, containers });
} catch (err) {
console.error(`Failed to inspect network ${id}:`, err);
attachments.push({ id, name, containers: [] });
}
}
return attachments;
}
type DockerContainerNetwork = DockerResource & {
networks: string[];
};
export async function getDockerContainerNetworks(): Promise<DockerContainerNetwork[]> {
let results: DockerContainerNetwork[] = [];
let containers: DockerResource[] = [];
try {
const result = await x("docker", [
"ps",
"-a",
"--format",
'{{.ID | printf "%.12s"}} {{.Names}}',
]);
const lines = stringToLines(result.stdout);
for (const line of lines) {
const [id, name] = lineToWords(line);
if (!id || !name) {
continue;
}
containers.push({ id, name });
}
} catch (err) {
console.error("Failed to list docker containers:", err);
}
for (const { id, name } of containers) {
try {
const inspectResult = await x("docker", [
"inspect",
"--format",
'{{ range $k, $v := .NetworkSettings.Networks }}{{ $k | printf "%.12s" }} {{ $v.Name }}\n{{ end }}',
id,
]);
const networks = stringToLines(inspectResult.stdout);
results.push({ id, name, networks });
} catch (err) {
console.error(`Failed to inspect container ${id}:`, err);
results.push({ id, name: String(err), networks: [] });
}
}
return results;
}
export type DockerDiagnostics = {
containers?: string[];
networks?: string[];
containerNetworks?: DockerContainerNetwork[];
networkAttachments?: DockerNetworkAttachment[];
};
export async function getDockerDiagnostics(): Promise<DockerDiagnostics> {
const [containers, networks, networkAttachments, containerNetworks] = await Promise.all([
getDockerContainers(),
getDockerNetworks(),
getDockerNetworkAttachments(),
getDockerContainerNetworks(),
]);
return {
containers,
networks,
containerNetworks,
networkAttachments,
};
}
@@ -0,0 +1,26 @@
import { expect } from "vitest";
import { heteroRunOpsPostgresTest } from "./index.js";
// The dedicated subset (NEW/PG17) has run-ops tables (TaskRun) but NOT control-plane tables
// (Organization); the legacy side (PG14) keeps the full control-plane schema.
heteroRunOpsPostgresTest(
"NEW (PG17) side has run-ops tables but NOT control-plane tables",
async ({ prisma14, prisma17 }) => {
// Cast regclass -> text: Prisma can't deserialize a bare `regclass` column.
const regclass = async (
p: { $queryRawUnsafe: (q: string) => Promise<unknown> },
table: string
): Promise<string | null> => {
const rows = (await p.$queryRawUnsafe(
`SELECT to_regclass('"${table}"')::text AS t`
)) as Array<{ t: string | null }>;
return rows[0]?.t ?? null;
};
expect(await regclass(prisma14, "Organization")).not.toBeNull();
expect(await regclass(prisma17, "TaskRun")).not.toBeNull();
expect(await regclass(prisma17, "Organization")).toBeNull();
},
// Booting two PG containers and pushing two schemas on first run far exceeds vitest's 5s default.
120_000
);
@@ -0,0 +1,827 @@
import { type ClickHouseClient, createClient } from "@clickhouse/client";
import { type StartedPostgreSqlContainer, PostgreSqlContainer } from "@testcontainers/postgresql";
import type { StartedRedisContainer } from "@testcontainers/redis";
import { PrismaClient } from "@trigger.dev/database";
import { RunOpsPrismaClient } from "@internal/run-ops-database";
import Redis, { type RedisOptions } from "ioredis";
import path from "path";
import { type StartedNetwork, Network } from "testcontainers";
import { type TestContext, test } from "vitest";
import {
type StartedClickHouseContainer,
ClickHouseContainer,
runClickhouseMigrations,
truncateClickhouseTables,
} from "./clickhouse";
import { getTaskMetadata, logCleanup, logSetup } from "./logs";
import { type MinIOConnectionConfig, type StartedMinIOContainer, MinIOContainer } from "./minio";
import {
createClickHouseContainer,
createElectricContainer,
createPostgresContainer,
createRedisContainer,
postgresUriWithDatabase,
pushDatabaseSchema,
pushRunOpsSchema,
useContainer,
withCiResourceLimits,
withContainerSetup,
} from "./utils";
export { assertNonNullable, createPostgresContainer } from "./utils";
export { laggingReplica, type LaggingModel } from "./laggingReplica";
export { logCleanup };
export type { MinIOConnectionConfig };
type NetworkContext = { network: StartedNetwork };
type PostgresContext = NetworkContext & {
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
};
type RedisContext = NetworkContext & {
redisContainer: StartedRedisContainer;
redisOptions: RedisOptions;
};
type ElectricContext = {
electricOrigin: string;
};
export type ContainerContext = NetworkContext & PostgresContext & RedisContext & ClickhouseContext;
export type PostgresAndRedisContext = NetworkContext & PostgresContext & RedisContext;
export type ContainerWithElectricAndRedisContext = ContainerContext & ElectricContext;
export type ContainerWithElectricContext = NetworkContext & PostgresContext & ElectricContext;
export type {
StartedClickHouseContainer,
StartedMinIOContainer,
StartedNetwork,
StartedPostgreSqlContainer,
StartedRedisContainer,
};
type Use<T> = (value: T) => Promise<void>;
export const network = async ({ task }: TestContext, use: Use<StartedNetwork>) => {
const testName = task.name;
logSetup("network: starting", { testName });
const start = Date.now();
const network = await new Network().start();
const startDurationMs = Date.now() - start;
const metadata = {
...getTaskMetadata(task),
networkId: network.getId().slice(0, 12),
networkName: network.getName(),
startDurationMs,
};
logSetup("network: started", metadata);
try {
await use(network);
} finally {
await logCleanup("network", network.stop(), metadata);
}
};
export const postgresContainer = async (
{ network, task }: { network: StartedNetwork } & TestContext,
use: Use<StartedPostgreSqlContainer>
) => {
const { container, metadata: _metadata } = await withContainerSetup({
name: "postgresContainer",
task,
setup: createPostgresContainer(network),
});
await useContainer("postgresContainer", { container, task, use: () => use(container) });
};
export const prisma = async (
{ postgresContainer, task }: { postgresContainer: StartedPostgreSqlContainer } & TestContext,
use: Use<PrismaClient>
) => {
const testName = task.name;
const url = postgresContainer.getConnectionUri();
console.log("Initializing Prisma with URL:", url);
const prisma = new PrismaClient({
datasources: {
db: {
url,
},
},
});
try {
await use(prisma);
} finally {
await logCleanup("prisma", prisma.$disconnect(), { testName });
}
};
const POSTGRES_TEMPLATE_DB = "template_db";
let pgCloneCounter = 0;
type PostgresTestContext = {
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
};
// --- Worker-scoped + per-test-isolated fixtures (shared by the standalone *Test and containerTest) ---
// The pattern: boot each container ONCE per worker; isolate per test cheaply (postgres = template
// clone, redis = FLUSHALL, clickhouse = TRUNCATE) instead of re-booting. Reset fixtures are `auto`
// so they run for every test even if it doesn't destructure them.
// Boot postgres ONCE per worker (module singleton, reaped by Ryuk on worker exit) and push the
// schema into a dedicated template db that nothing else connects to (so CREATE DATABASE ... TEMPLATE
// never trips on an active session).
let workerPostgresContainer: Promise<StartedPostgreSqlContainer> | undefined;
const getWorkerPostgresContainer = () => {
if (!workerPostgresContainer) {
workerPostgresContainer = (async () => {
const container = await withCiResourceLimits(new PostgreSqlContainer("docker.io/postgres:14"))
.withCommand(["-c", "listen_addresses=*", "-c", "wal_level=logical"])
.start();
// Create the template db explicitly via an admin connection (the same primitive the per-test
// clone uses) instead of relying on `prisma db push` to create a missing database. That
// create-if-missing path behaves differently on CI and - because push errors were swallowed -
// surfaced only later as a confusing "template database template_db does not exist" at clone
// time. Pushing into an already-existing db is the path the pre-worker-scope code always used.
const admin = new PrismaClient({
datasources: {
db: { url: postgresUriWithDatabase(container.getConnectionUri(), "postgres") },
},
});
await admin.$executeRawUnsafe(`CREATE DATABASE "${POSTGRES_TEMPLATE_DB}"`);
await admin.$disconnect();
await pushDatabaseSchema(
postgresUriWithDatabase(container.getConnectionUri(), POSTGRES_TEMPLATE_DB)
);
return container;
})();
}
return workerPostgresContainer;
};
// --- Heterogeneous PG14 + PG17 fixture ---
// `und-x-icu` is a predefined ICU collation available by default in BOTH PG14 and PG17, so it is the
// version-symmetric per-column source of truth for cross-version sort equality.
export const HETERO_PINNED_ICU_COLLATION = "und-x-icu";
// PG17 worker singleton mirroring getWorkerPostgresContainer. PG17 supports the ICU cluster locale
// provider (PG14 does not - it arrived in PG15), so only this side sets the cluster locale; the real
// cross-version guarantee is the per-column COLLATE in the proof test.
async function bootstrapPg17TemplateContainer(
pushSchema: (databaseUrl: string) => Promise<unknown>
): Promise<StartedPostgreSqlContainer> {
const container = await withCiResourceLimits(new PostgreSqlContainer("docker.io/postgres:17"))
.withCommand(["-c", "listen_addresses=*", "-c", "wal_level=logical"])
.withEnvironment({
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=en-US --encoding=UTF8",
})
.start();
const admin = new PrismaClient({
datasources: {
db: { url: postgresUriWithDatabase(container.getConnectionUri(), "postgres") },
},
});
await admin.$executeRawUnsafe(`CREATE DATABASE "${POSTGRES_TEMPLATE_DB}"`);
await admin.$disconnect();
await pushSchema(postgresUriWithDatabase(container.getConnectionUri(), POSTGRES_TEMPLATE_DB));
return container;
}
let workerPostgresContainer17: Promise<StartedPostgreSqlContainer> | undefined;
const getWorkerPostgresContainer17 = () => {
if (!workerPostgresContainer17) {
workerPostgresContainer17 = bootstrapPg17TemplateContainer(pushDatabaseSchema);
}
return workerPostgresContainer17;
};
// PG17 worker singleton for the DEDICATED run-ops fixture. This is a SEPARATE container from
// getWorkerPostgresContainer17 on purpose: that one's template db has the FULL @trigger.dev/database
// schema pushed (consumed by heteroPostgresTest), whereas this one pushes the dedicated run-ops
// SUBSET schema. Keeping them apart avoids a schema collision in the shared template db.
let runOpsWorkerPostgresContainer17: Promise<StartedPostgreSqlContainer> | undefined;
const getRunOpsWorkerPostgresContainer17 = () => {
if (!runOpsWorkerPostgresContainer17) {
runOpsWorkerPostgresContainer17 = bootstrapPg17TemplateContainer(pushRunOpsSchema);
}
return runOpsWorkerPostgresContainer17;
};
// Per test: clone a fresh database from the template (fast filesystem copy), then hand back a view
// of the shared container whose connection points at the clone. This keeps prisma AND any code that
// reads postgresContainer.getConnectionUri()/getDatabase() (e.g. logical replication) on the SAME
// isolated database - and it's parallel-ready (each test owns its db).
const clonedPostgresContainer = async ({}, use: Use<StartedPostgreSqlContainer>) => {
const container = await getWorkerPostgresContainer();
const baseUri = container.getConnectionUri();
const cloneDb = `test_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
const cloneUri = postgresUriWithDatabase(baseUri, cloneDb);
const view = new Proxy(container, {
get(target, prop, receiver) {
if (prop === "getConnectionUri") return () => cloneUri;
if (prop === "getDatabase") return () => cloneDb;
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
try {
await use(view);
} finally {
await dropCloneDatabase(baseUri, cloneDb);
}
};
const createDatabaseFromTemplate = async (baseUri: string, cloneDb: string) => {
const admin = new PrismaClient({
datasources: { db: { url: postgresUriWithDatabase(baseUri, "postgres") } },
});
try {
await admin.$executeRawUnsafe(
`CREATE DATABASE "${cloneDb}" TEMPLATE "${POSTGRES_TEMPLATE_DB}"`
);
} finally {
await admin.$disconnect();
}
};
// Best-effort drop so clones don't pile up in the worker's pg over a long suite. WITH (FORCE)
// terminates any lingering backends (pg 13+). A failed drop is harmless - the whole container is
// reaped on worker exit - so we never let cleanup fail the test.
const dropCloneDatabase = async (baseUri: string, cloneDb: string) => {
const cleanup = new PrismaClient({
datasources: { db: { url: postgresUriWithDatabase(baseUri, "postgres") } },
});
try {
await cleanup.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${cloneDb}" WITH (FORCE)`);
} catch {
// ignore - reaped with the container anyway
} finally {
await cleanup.$disconnect();
}
};
// A second migrated-but-empty database on the same worker postgres, cloned from the schema
// template. For tests that need to simulate a read replica that hasn't caught up: schema
// present, rows absent. Lazy - only booted when a test destructures it.
const schemaOnlyPrismaFixture = async ({}: {}, use: Use<PrismaClient>) => {
const container = await getWorkerPostgresContainer();
const baseUri = container.getConnectionUri();
const cloneDb = `schema_only_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
const prisma = new PrismaClient({
datasources: { db: { url: postgresUriWithDatabase(baseUri, cloneDb) } },
});
try {
await use(prisma);
} finally {
await logCleanup("schemaOnlyPrisma", prisma.$disconnect());
await dropCloneDatabase(baseUri, cloneDb);
}
};
const prismaFromContainer = async (
{ postgresContainer }: { postgresContainer: StartedPostgreSqlContainer },
use: Use<PrismaClient>
) => {
const prisma = new PrismaClient({
datasources: { db: { url: postgresContainer.getConnectionUri() } },
});
try {
await use(prisma);
} finally {
await logCleanup("prisma", prisma.$disconnect());
}
};
export const postgresTest = test.extend<PostgresTestContext>({
postgresContainer: clonedPostgresContainer,
prisma: prismaFromContainer,
});
type HeteroPostgresTestContext = {
// PG14 (legacy / control-plane DB analog)
postgresContainer14: StartedPostgreSqlContainer;
prisma14: PrismaClient;
uri14: string;
// PG17 (new / dedicated run-ops DB analog)
postgresContainer17: StartedPostgreSqlContainer;
prisma17: PrismaClient;
uri17: string;
pinnedCollation: string; // === HETERO_PINNED_ICU_COLLATION
};
// Hands a test two prisma clients + two connection URIs (one PG14, one PG17) over the same migrated
// schema, each on a fresh per-test clone of its version's template. Consumed only by explicit
// cross-version test files - never wired into a product fixture.
export const heteroPostgresTest = test.extend<HeteroPostgresTestContext>({
postgresContainer14: async ({}, use) => {
await use(await getWorkerPostgresContainer());
},
postgresContainer17: async ({}, use) => {
await use(await getWorkerPostgresContainer17());
},
uri14: async ({ postgresContainer14 }, use) => {
const baseUri = postgresContainer14.getConnectionUri();
const cloneDb = `hetero14_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
try {
await use(postgresUriWithDatabase(baseUri, cloneDb));
} finally {
await dropCloneDatabase(baseUri, cloneDb);
}
},
uri17: async ({ postgresContainer17 }, use) => {
const baseUri = postgresContainer17.getConnectionUri();
const cloneDb = `hetero17_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
try {
await use(postgresUriWithDatabase(baseUri, cloneDb));
} finally {
await dropCloneDatabase(baseUri, cloneDb);
}
},
prisma14: async ({ uri14 }, use) => {
const prisma = new PrismaClient({ datasources: { db: { url: uri14 } } });
try {
await use(prisma);
} finally {
await prisma.$disconnect();
}
},
prisma17: async ({ uri17 }, use) => {
const prisma = new PrismaClient({ datasources: { db: { url: uri17 } } });
try {
await use(prisma);
} finally {
await prisma.$disconnect();
}
},
pinnedCollation: async ({}, use) => {
await use(HETERO_PINNED_ICU_COLLATION);
},
});
type HeteroRunOpsPostgresTestContext = {
// PG14 (legacy / control-plane DB analog) — full @trigger.dev/database control-plane schema.
postgresContainer14: StartedPostgreSqlContainer;
prisma14: PrismaClient;
uri14: string;
// PG17 (new / dedicated run-ops DB) — the @internal/run-ops-database SUBSET schema.
postgresContainer17: StartedPostgreSqlContainer;
prisma17: RunOpsPrismaClient;
uri17: string;
};
// Additive sibling of heteroPostgresTest for the dedicated run-ops migration: prisma14 is the full
// control-plane schema on PG14 (legacy), prisma17 is a RunOpsPrismaClient over the dedicated SUBSET
// schema on a SEPARATE PG17 container. Lets a test prove the two sides carry different schemas
// without disturbing the existing heteroPostgresTest (which keeps the full schema on both sides).
export const heteroRunOpsPostgresTest = test.extend<HeteroRunOpsPostgresTestContext>({
postgresContainer14: async ({}, use) => {
await use(await getWorkerPostgresContainer());
},
postgresContainer17: async ({}, use) => {
await use(await getRunOpsWorkerPostgresContainer17());
},
uri14: async ({ postgresContainer14 }, use) => {
const baseUri = postgresContainer14.getConnectionUri();
const cloneDb = `heteroRunOps14_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
try {
await use(postgresUriWithDatabase(baseUri, cloneDb));
} finally {
await dropCloneDatabase(baseUri, cloneDb);
}
},
uri17: async ({ postgresContainer17 }, use) => {
const baseUri = postgresContainer17.getConnectionUri();
const cloneDb = `heteroRunOps17_${pgCloneCounter++}`;
await createDatabaseFromTemplate(baseUri, cloneDb);
try {
await use(postgresUriWithDatabase(baseUri, cloneDb));
} finally {
await dropCloneDatabase(baseUri, cloneDb);
}
},
prisma14: async ({ uri14 }, use) => {
const prisma = new PrismaClient({ datasources: { db: { url: uri14 } } });
try {
await use(prisma);
} finally {
await prisma.$disconnect();
}
},
prisma17: async ({ uri17 }, use) => {
const prisma = new RunOpsPrismaClient({ datasources: { db: { url: uri17 } } });
try {
await use(prisma);
} finally {
await prisma.$disconnect();
}
},
});
export const redisContainer = async (
{ network, task }: { network: StartedNetwork } & TestContext,
use: Use<StartedRedisContainer>
) => {
const { container, metadata: _metadata } = await withContainerSetup({
name: "redisContainer",
task,
setup: createRedisContainer({
port: 6379,
network,
}),
});
await useContainer("redisContainer", { container, task, use: () => use(container) });
};
export const redisOptions = async (
{ redisContainer }: { redisContainer: StartedRedisContainer },
use: Use<RedisOptions>
) => {
const options: RedisOptions = {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
maxRetriesPerRequest: 20, // Lower the retry attempts
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
connectTimeout: 10000, // 10 seconds
// Add more robust connection options
enableOfflineQueue: true,
reconnectOnError: (err) => {
const targetError = "READONLY";
if (err.message.includes(targetError)) {
return true;
}
return false;
},
enableAutoPipelining: true,
autoResubscribe: true,
autoResendUnfulfilledCommands: true,
lazyConnect: false,
showFriendlyErrorStack: true,
};
console.log("Redis options", options);
await use(options);
};
// Worker-scoped redis: booted once per worker, FLUSHALL per test. Big win for redis-heavy files
// (buffer.test.ts: 88 boots -> 1). Safe ONLY for tests that don't leave background redis work
// (a Worker loop, BatchQueue) running past the test body - use isolatedRedisTest for those.
const bootWorkerRedis = async ({}, use: Use<StartedRedisContainer>) => {
const { container } = await createRedisContainer({ port: 6379 });
try {
await use(container);
} finally {
await container.stop({ timeout: 0 });
}
};
const flushRedis = async (
{ redisContainer }: { redisContainer: StartedRedisContainer },
use: Use<void>
) => {
const redis = new Redis({
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
maxRetriesPerRequest: 3,
});
try {
await redis.flushall();
} finally {
redis.disconnect();
}
await use();
};
type RedisTestContext = {
redisContainer: StartedRedisContainer;
resetRedis: void;
redisOptions: RedisOptions;
};
// Worker-scoped redis (boots once, FLUSHALL between tests). Use isolatedRedisTest for tests that run
// background redis work (redis-worker Workers, BatchQueue) past the test body - see its note + README.
export const redisTest = test.extend<RedisTestContext>({
redisContainer: [bootWorkerRedis, { scope: "worker" }],
resetRedis: [flushRedis, { auto: true }],
redisOptions,
});
// Per-test redis for tests with background redis work (redis-worker Workers, BatchQueue) that can
// outlive the test body - a shared redis would let leaked work hit a closed connection / next test
// ("Connection is closed"). Boot is kept fast (see createRedisContainer).
export const isolatedRedisTest = test.extend<RedisContext>({
network,
redisContainer,
redisOptions,
});
const electricOrigin = async (
{
postgresContainer,
network,
task,
}: { postgresContainer: StartedPostgreSqlContainer; network: StartedNetwork } & TestContext,
use: Use<string>
) => {
const {
origin,
container,
metadata: _metadata,
} = await withContainerSetup({
name: "electricContainer",
task,
setup: createElectricContainer(postgresContainer, network),
});
await useContainer("electricContainer", { container, task, use: () => use(origin) });
};
const clickhouseContainer = async (
{ network, task }: { network: StartedNetwork } & TestContext,
use: Use<StartedClickHouseContainer>
) => {
const { container, metadata: _metadata } = await withContainerSetup({
name: "clickhouseContainer",
task,
setup: createClickHouseContainer(network),
});
await useContainer("clickhouseContainer", { container, task, use: () => use(container) });
};
const clickhouseClient = async (
{ clickhouseContainer, task }: { clickhouseContainer: StartedClickHouseContainer } & TestContext,
use: Use<ClickHouseClient>
) => {
const testName = task.name;
const client = createClient({ url: clickhouseContainer.getConnectionUrl() });
try {
await use(client);
} finally {
await logCleanup("clickhouseClient", client.close(), { testName });
}
};
type ClickhouseContext = {
network: StartedNetwork;
clickhouseContainer: StartedClickHouseContainer;
clickhouseClient: ClickHouseClient;
};
const clickhouseMigrationsPath = path.resolve(__dirname, "../../clickhouse/schema");
type ClickhouseTestContext = {
clickhouseContainer: StartedClickHouseContainer;
resetClickhouse: void;
clickhouseClient: ClickHouseClient;
};
// Boot + migrate clickhouse once per worker.
const bootWorkerClickhouse = async ({}, use: Use<StartedClickHouseContainer>) => {
const container = await withCiResourceLimits(new ClickHouseContainer()).start();
const client = createClient({ url: container.getConnectionUrl() });
await client.ping();
await runClickhouseMigrations(client, clickhouseMigrationsPath);
await client.close();
try {
await use(container);
} finally {
await container.stop({ timeout: 0 });
}
};
// Per test: truncate all tables on the shared clickhouse (auto fixture so it runs for every test).
const truncateClickhouseFixture = async (
{ clickhouseContainer }: { clickhouseContainer: StartedClickHouseContainer },
use: Use<void>
) => {
const client = createClient({ url: clickhouseContainer.getConnectionUrl() });
await truncateClickhouseTables(client);
await client.close();
await use();
};
const scopedClickhouseClient = async (
{ clickhouseContainer }: { clickhouseContainer: StartedClickHouseContainer },
use: Use<ClickHouseClient>
) => {
const client = createClient({ url: clickhouseContainer.getConnectionUrl() });
try {
await use(client);
} finally {
await logCleanup("clickhouseClient", client.close());
}
};
export const clickhouseTest = test.extend<ClickhouseTestContext>({
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
clickhouseClient: scopedClickhouseClient,
});
// NOTE: per-test containers (not worker-scoped) - the replication package does logical replication
// (slots/publications/REPLICA IDENTITY), which doesn't play nicely with a shared container +
// template-clone. A dedicated container per test is the correct, isolated choice here.
export const postgresAndRedisTest = test.extend<PostgresAndRedisContext>({
network,
postgresContainer,
prisma,
redisContainer,
redisOptions,
});
type ContainerTestContext = {
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
schemaOnlyPrisma: PrismaClient;
redisContainer: StartedRedisContainer;
resetRedis: void;
redisOptions: RedisOptions;
clickhouseContainer: StartedClickHouseContainer;
resetClickhouse: void;
clickhouseClient: ClickHouseClient;
};
// The workhorse fixture (~36 files). Postgres (template-clone), Redis (FLUSHALL) and ClickHouse
// (truncate) all boot once per worker - no per-test container boots. Use containerTestWithIsolatedRedis
// for tests that run background redis work (BatchQueue, redis-worker Workers) past the test body.
export const containerTest = test.extend<ContainerTestContext>({
postgresContainer: clonedPostgresContainer,
prisma: prismaFromContainer,
schemaOnlyPrisma: schemaOnlyPrismaFixture,
redisContainer: [bootWorkerRedis, { scope: "worker" }],
resetRedis: [flushRedis, { auto: true }],
redisOptions,
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
clickhouseClient: scopedClickhouseClient,
});
type ContainerWithIsolatedRedisContext = {
network: StartedNetwork;
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
redisContainer: StartedRedisContainer;
redisOptions: RedisOptions;
clickhouseContainer: StartedClickHouseContainer;
resetClickhouse: void;
clickhouseClient: ClickHouseClient;
};
// Same as containerTest but Redis is PER-TEST - for tests whose background redis work (BatchQueue,
// Workers) outlives the test body and would otherwise hit a closed/shared connection.
export const containerTestWithIsolatedRedis = test.extend<ContainerWithIsolatedRedisContext>({
network,
postgresContainer: clonedPostgresContainer,
prisma: prismaFromContainer,
redisContainer,
redisOptions,
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
clickhouseClient: scopedClickhouseClient,
});
type ContainerWithIsolatedRedisNoClickhouseContext = {
network: StartedNetwork;
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
redisContainer: StartedRedisContainer;
redisOptions: RedisOptions;
};
// Like containerTestWithIsolatedRedis (template-clone Postgres + per-test Redis) but with no
// ClickHouse - for suites that touch Postgres + Redis but never ClickHouse, avoiding its boot+migrate.
export const containerTestWithIsolatedRedisNoClickhouse =
test.extend<ContainerWithIsolatedRedisNoClickhouseContext>({
network,
postgresContainer: clonedPostgresContainer,
prisma: prismaFromContainer,
redisContainer,
redisOptions,
});
// For tests that exercise the Postgres -> ClickHouse logical-replication pipeline (WAL slots,
// publications, REPLICA IDENTITY). These need a dedicated Postgres per test - the worker-scoped +
// template-clone model used by containerTest doesn't carry logical replication across cloned dbs.
// Postgres is per-test (the WAL slot/publication lives in the db it writes to); ClickHouse is
// worker-scoped + truncated (the pipeline writes pg->clickhouse and a shared+truncated clickhouse is
// fine). Redis is per-test too (background work safety, same as containerTest).
type ReplicationContainerTestContext = {
network: StartedNetwork;
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
redisContainer: StartedRedisContainer;
redisOptions: RedisOptions;
clickhouseContainer: StartedClickHouseContainer;
resetClickhouse: void;
clickhouseClient: ClickHouseClient;
};
export const replicationContainerTest = test.extend<ReplicationContainerTestContext>({
network,
postgresContainer,
prisma,
redisContainer,
redisOptions,
clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }],
resetClickhouse: [truncateClickhouseFixture, { auto: true }],
clickhouseClient: scopedClickhouseClient,
});
export const containerWithElectricTest = test.extend<ContainerWithElectricContext>({
network,
postgresContainer,
prisma,
electricOrigin,
});
export const containerWithElectricAndRedisTest = test.extend<ContainerWithElectricAndRedisContext>({
network,
postgresContainer,
prisma,
redisContainer,
redisOptions,
electricOrigin,
clickhouseContainer,
clickhouseClient,
});
// Boot minio once per worker; reset the bucket per test (auto fixture).
const bootWorkerMinio = async ({}, use: Use<StartedMinIOContainer>) => {
const container = await withCiResourceLimits(new MinIOContainer()).start();
try {
await use(container);
} finally {
await container.stop({ timeout: 0 });
}
};
const minioReset = async (
{ minioContainer }: { minioContainer: StartedMinIOContainer },
use: Use<void>
) => {
await minioContainer.resetBucket();
await use();
};
const minioConfig = async (
{ minioContainer }: { minioContainer: StartedMinIOContainer },
use: Use<MinIOConnectionConfig>
) => {
await use(minioContainer.getConnectionConfig());
};
type MinioTestContext = {
minioContainer: StartedMinIOContainer;
resetMinio: void;
minioConfig: MinIOConnectionConfig;
};
export const minioTest = test.extend<MinioTestContext>({
minioContainer: [bootWorkerMinio, { scope: "worker" }],
resetMinio: [minioReset, { auto: true }],
minioConfig,
});
type PostgresAndMinioTestContext = {
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
minioContainer: StartedMinIOContainer;
resetMinio: void;
minioConfig: MinIOConnectionConfig;
};
export const postgresAndMinioTest = test.extend<PostgresAndMinioTestContext>({
postgresContainer: clonedPostgresContainer,
prisma: prismaFromContainer,
minioContainer: [bootWorkerMinio, { scope: "worker" }],
resetMinio: [minioReset, { auto: true }],
minioConfig,
});
@@ -0,0 +1,86 @@
// A read-replica that has NOT caught up to its primary, for reproducing read-your-writes hazards the
// zero-lag single-DB test harness can't surface. Wraps a real Prisma client; for the configured
// models it serves STALE reads instead of live ones. Other models and all writes forward untouched.
// Modes: "missing" (row not replicated yet -> null/[]/0, *OrThrow throws); "frozen" (row out of date
// -> returns the provided pre-write snapshot rows, matched on top-level scalar `where` equality).
// `wasHit` asserts the stale replica was actually consulted.
const READ_METHODS = new Set([
"findFirst",
"findUnique",
"findFirstOrThrow",
"findUniqueOrThrow",
"findMany",
"count",
]);
export type LaggingModel =
| { model: string; mode: "missing" }
| { model: string; mode: "frozen"; rows: readonly Record<string, unknown>[] };
// Match a frozen row against a Prisma `where` using top-level scalar equality only (enough for the
// friendlyId / id / environmentId lookups these reads use); nested filters are treated as "matches".
function whereMatches(row: Record<string, unknown>, where: unknown): boolean {
if (where == null || typeof where !== "object") return true;
return Object.entries(where as Record<string, unknown>).every(([key, val]) => {
if (val !== null && typeof val === "object") return true;
return row[key] === val;
});
}
export function laggingReplica<C extends object>(
real: C,
configs: readonly LaggingModel[]
): { client: C; wasHit: (model?: string) => boolean } {
const byModel = new Map(configs.map((c) => [c.model, c]));
const hits = new Set<string>();
const makeModelProxy = (modelName: string, realModel: object, cfg: LaggingModel) =>
new Proxy(realModel, {
get(target, prop) {
if (typeof prop === "string" && READ_METHODS.has(prop)) {
return async (args?: { where?: unknown }) => {
hits.add(modelName);
if (cfg.mode === "missing") {
if (prop === "findMany") return [];
if (prop === "count") return 0;
if (prop === "findFirstOrThrow" || prop === "findUniqueOrThrow") {
throw new Error(
`laggingReplica: ${modelName}.${prop} - row not visible on replica yet`
);
}
return null;
}
const matched = cfg.rows.filter((r) => whereMatches(r, args?.where));
if (prop === "findMany") return matched;
if (prop === "count") return matched.length;
const first = matched[0] ?? null;
if ((prop === "findFirstOrThrow" || prop === "findUniqueOrThrow") && !first) {
throw new Error(`laggingReplica: ${modelName}.${prop} - no frozen row matched`);
}
return first;
};
}
const value = (target as Record<string | symbol, unknown>)[prop];
return typeof value === "function"
? (value as (...a: unknown[]) => unknown).bind(target)
: value;
},
});
const client = new Proxy(real, {
get(target, prop) {
const cfg = typeof prop === "string" ? byModel.get(prop) : undefined;
const delegate = cfg ? (target as Record<string, unknown>)[prop as string] : undefined;
if (cfg && delegate && typeof delegate === "object") {
return makeModelProxy(prop as string, delegate, cfg);
}
const value = (target as Record<string | symbol, unknown>)[prop];
return typeof value === "function"
? (value as (...a: unknown[]) => unknown).bind(target)
: value;
},
}) as C;
return { client, wasHit: (model) => (model ? hits.has(model) : hits.size > 0) };
}
@@ -0,0 +1,105 @@
import { env, isCI } from "std-env";
import type { TestContext } from "vitest";
import type { DockerDiagnostics } from "./docker";
import { getDockerDiagnostics } from "./docker";
import type { StartedTestContainer } from "testcontainers";
let setupOrder = 0;
// Emit timing JSON in CI, or locally when TESTCONTAINERS_TIMING is set (drives the local timing harness)
const emitTimingLogs = isCI || !!env.TESTCONTAINERS_TIMING;
export function logSetup(resource: string, metadata: Record<string, unknown>) {
const order = setupOrder++;
if (!emitTimingLogs) {
return;
}
console.log(
JSON.stringify({
type: "setup",
order,
resource,
timestamp: new Date().toISOString(),
...metadata,
})
);
}
export function getContainerMetadata(container: StartedTestContainer) {
return {
containerName: container.getName(),
containerId: container.getId().slice(0, 12),
containerNetworkNames: container.getNetworkNames(),
};
}
export function getTaskMetadata(task: TestContext["task"]) {
return {
testName: task.name,
};
}
let cleanupOrder = 0;
let activeCleanups = 0;
/**
* Logs the cleanup of a resource.
* @param resource - The resource that is being cleaned up.
* @param promise - The cleanup promise to await..
*/
export async function logCleanup(
resource: string,
promise: Promise<unknown>,
metadata: Record<string, unknown> = {}
) {
const start = new Date();
const order = cleanupOrder++;
const activeAtStart = ++activeCleanups;
let error: unknown = null;
try {
await promise;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
const end = new Date();
const durationMs = end.getTime() - start.getTime();
const activeAtEnd = --activeCleanups;
const parallel = activeAtStart > 1 || activeAtEnd > 0;
if (!emitTimingLogs) {
return;
}
let dockerDiagnostics: DockerDiagnostics = {};
// Only run docker diagnostics if there was an error or cleanup took longer than 5s
if (error || durationMs > 5000 || env.DOCKER_DIAGNOSTICS) {
try {
dockerDiagnostics = await getDockerDiagnostics();
} catch (diagnosticErr) {
console.error("Failed to get docker diagnostics:", diagnosticErr);
}
}
console.log(
JSON.stringify({
type: "cleanup",
order,
resource,
durationMs,
start: start.toISOString(),
end: end.toISOString(),
parallel,
error,
activeAtStart,
activeAtEnd,
...metadata,
...dockerDiagnostics,
})
);
}
@@ -0,0 +1,145 @@
import type { StartedTestContainer } from "testcontainers";
import { AbstractStartedContainer, GenericContainer, Wait } from "testcontainers";
import { x } from "tinyexec";
const MINIO_PORT = 9000;
export type MinIOConnectionConfig = {
baseUrl: string;
accessKeyId: string;
secretAccessKey: string;
region: string;
};
export class MinIOContainer extends GenericContainer {
private accessKeyId = "minioadmin";
private secretAccessKey = "minioadmin";
private region = "us-east-1";
constructor(image = "minio/minio:latest") {
super(image);
this.withExposedPorts(MINIO_PORT);
this.withCommand(["server", "/data"]);
this.withWaitStrategy(Wait.forLogMessage(/API:/));
this.withStartupTimeout(120_000);
}
public withAccessKeyId(accessKeyId: string): this {
this.accessKeyId = accessKeyId;
return this;
}
public withSecretAccessKey(secretAccessKey: string): this {
this.secretAccessKey = secretAccessKey;
return this;
}
public withRegion(region: string): this {
this.region = region;
return this;
}
public override async start(): Promise<StartedMinIOContainer> {
this.withEnvironment({
MINIO_ROOT_USER: this.accessKeyId,
MINIO_ROOT_PASSWORD: this.secretAccessKey,
});
const startedContainer = await super.start();
// Create the "packets" bucket using MinIO client
await x(
"docker",
[
"exec",
startedContainer.getId(),
"mc",
"alias",
"set",
"local",
"http://localhost:9000",
this.accessKeyId,
this.secretAccessKey,
],
{ throwOnError: true }
);
await x("docker", ["exec", startedContainer.getId(), "mc", "mb", "local/packets"], {
throwOnError: true,
});
return new StartedMinIOContainer(
startedContainer,
this.accessKeyId,
this.secretAccessKey,
this.region
);
}
}
export class StartedMinIOContainer extends AbstractStartedContainer {
constructor(
startedTestContainer: StartedTestContainer,
private readonly accessKeyId: string,
private readonly secretAccessKey: string,
private readonly region: string
) {
super(startedTestContainer);
}
public getPort(): number {
return super.getMappedPort(MINIO_PORT);
}
public getAccessKeyId(): string {
return this.accessKeyId;
}
public getSecretAccessKey(): string {
return this.secretAccessKey;
}
public getRegion(): string {
return this.region;
}
/**
* Gets the base URL (protocol, host and mapped port) for the MinIO container.
* Example: `http://localhost:32768`
*/
public getBaseUrl(): string {
const protocol = "http";
const host = this.getHost();
const port = this.getPort();
return `${protocol}://${host}:${port}`;
}
/**
* Empties the bucket between tests on a reused container (the "local" mc alias and the bucket are
* created at boot). Recreates the bucket so each test starts from the same empty state.
*/
public async resetBucket(bucket = "packets"): Promise<void> {
await x(
"docker",
["exec", this.getId(), "mc", "rm", "--recursive", "--force", `local/${bucket}`],
{
throwOnError: false,
}
);
await x("docker", ["exec", this.getId(), "mc", "mb", "--ignore-existing", `local/${bucket}`], {
throwOnError: true,
});
}
/**
* Gets connection configuration suitable for object storage clients.
*/
public getConnectionConfig(): MinIOConnectionConfig {
return {
baseUrl: this.getBaseUrl(),
accessKeyId: this.getAccessKeyId(),
secretAccessKey: this.getSecretAccessKey(),
region: this.getRegion(),
};
}
}
@@ -0,0 +1,129 @@
// Authored as plain CommonJS (NOT .ts) on purpose. vitest loads each package's vitest.config.ts by
// bundling it, and it EXTERNALIZES this workspace subpath - node then loads this file verbatim. A .ts
// here reaches node as raw TypeScript and crashes config loading on CI's pinned node 20 (no type
// stripping: `SyntaxError`). Keeping it dependency-free JS - and importing nothing from the ESM-only
// `vitest/node` - makes it loadable on every node. Types for consumers live in sequencer.d.cts.
const { existsSync, readFileSync } = require("node:fs");
const path = require("node:path");
// Walk up from the package dir (cwd at config-load time) to the monorepo root (pnpm-workspace.yaml).
function findRepoRoot(start) {
let dir = start;
for (let i = 0; i < 20; i++) {
if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return start;
}
// test-timings.json lives at the monorepo root: { "<repo-relative path>": <ms> }
const REPO_ROOT = findRepoRoot(process.cwd());
const TIMINGS_PATH = path.resolve(REPO_ROOT, "test-timings.json");
let cachedTimings;
function loadTimings() {
if (!cachedTimings) {
// A MISSING file is a legitimate state (no timings configured yet => count-based split). But a
// file that EXISTS and won't parse is a real problem with a committed artifact we control - fail
// loud rather than silently degrading sharding (silent fallbacks are what hid earlier bugs).
if (!existsSync(TIMINGS_PATH)) {
cachedTimings = {};
return cachedTimings;
}
try {
cachedTimings = JSON.parse(readFileSync(TIMINGS_PATH, "utf-8"));
} catch (error) {
throw new Error(`Failed to parse ${TIMINGS_PATH}: ${error?.message ?? error}`);
}
}
return cachedTimings;
}
function median(nums) {
if (nums.length === 0) return 1;
const sorted = [...nums].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
// Stable per-package offset (derived from the package dir) so each package's heaviest file - which
// LPT always drops into bin 0 - maps to a DIFFERENT shard. Without it, a serial multi-package job
// (`turbo --concurrency=1 --filter "@internal/*"`) stacks every package's heaviest file into shard 1.
// It's a rotation of the bin->shard mapping, so coverage stays exact (each file runs once).
function packageOffset(specs, count) {
if (specs.length === 0) return 0;
const rel = path.relative(REPO_ROOT, specs[0].moduleId);
const key = rel.split(path.sep).slice(0, 2).join("/");
// FNV-1a - spreads similar sibling package names (e.g. internal-packages/*) far better than a
// simple polynomial hash mod count, which collided run-engine + schedule-engine onto one shard.
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % count;
}
/**
* Duration-weighted interpretation of `--shard=i/N`. Instead of vitest's default file-count split,
* this greedily bin-packs test files by recorded duration (test-timings.json at the repo root;
* unknown/new files get the median) so each shard does roughly equal work.
*
* The packing is fully deterministic (sort by duration desc, then moduleId) so every shard computes
* the identical bins and just takes its own - no file runs twice or gets dropped. Falls back to the
* full set when no shard is configured, and to ~count-based when no timings exist.
*
* Implemented as a standalone TestSequencer (not extending BaseSequencer) so this file never imports
* `vitest/node` - see the header note.
*/
class DurationShardingSequencer {
constructor(ctx) {
this.ctx = ctx;
}
// Deterministic order (heaviest first, then moduleId) - stable across shards and a sensible
// in-shard run order, replacing BaseSequencer's default sort we no longer inherit.
async sort(files) {
const timings = loadTimings();
const fallback = median(Object.values(timings));
return [...files].sort((a, b) => {
const am = timings[path.relative(REPO_ROOT, a.moduleId)] ?? fallback;
const bm = timings[path.relative(REPO_ROOT, b.moduleId)] ?? fallback;
return bm - am || a.moduleId.localeCompare(b.moduleId);
});
}
async shard(specs) {
const shard = this.ctx.config.shard;
if (!shard || specs.length === 0) {
return specs;
}
const timings = loadTimings();
const fallback = median(Object.values(timings));
const weighted = specs
.map((spec) => ({
spec,
ms: timings[path.relative(REPO_ROOT, spec.moduleId)] ?? fallback,
}))
.sort((a, b) => b.ms - a.ms || a.spec.moduleId.localeCompare(b.spec.moduleId));
const bins = Array.from({ length: shard.count }, () => ({ total: 0, specs: [] }));
for (const { spec, ms } of weighted) {
const lightest = bins.reduce((min, bin) => (bin.total < min.total ? bin : min));
lightest.total += ms;
lightest.specs.push(spec);
}
const offset = packageOffset(specs, shard.count);
return bins[(shard.index - 1 + offset) % shard.count].specs;
}
}
module.exports = { DurationShardingSequencer };
@@ -0,0 +1,13 @@
import type { TestSequencer, TestSpecification, Vitest } from "vitest/node";
/**
* Duration-weighted `--shard=i/N`: bin-packs test files by recorded duration (test-timings.json at
* the repo root) so each shard does roughly equal work. The runtime lives in `sequencer.cjs` (plain
* JS, so vitest config loading can load it on any node - see that file's header); this declaration
* supplies the types for configs that wire it via `sequence: { sequencer: DurationShardingSequencer }`.
*/
export declare class DurationShardingSequencer implements TestSequencer {
constructor(ctx: Vitest);
sort(files: TestSpecification[]): Promise<TestSpecification[]>;
shard(files: TestSpecification[]): Promise<TestSpecification[]>;
}
@@ -0,0 +1,369 @@
import { createClient } from "@clickhouse/client";
import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import type { StartedRedisContainer } from "@testcontainers/redis";
import { RedisContainer } from "@testcontainers/redis";
import { PrismaClient } from "@trigger.dev/database";
import { tryCatch } from "@trigger.dev/core";
import Redis from "ioredis";
import path from "path";
import { isDebug } from "std-env";
import type { StartedNetwork, StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import { x } from "tinyexec";
import type { TestContext } from "vitest";
import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse";
import { MinIOContainer } from "./minio";
import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs";
/** Returns the container's connection URI with the database path swapped to `database`. */
export function postgresUriWithDatabase(uri: string, database: string): string {
const url = new URL(uri);
url.pathname = `/${database}`;
return url.toString();
}
/** Pushes the Prisma schema into the database at `databaseUrl` (which must already exist). */
export async function pushDatabaseSchema(databaseUrl: string) {
const databasePath = path.resolve(__dirname, "../../database");
return pushPrismaSchema({
prismaBin: `${databasePath}/node_modules/.bin/prisma`,
schemaPath: `${databasePath}/prisma/schema.prisma`,
// The full @trigger.dev/database datasource reads DATABASE_URL/DIRECT_URL.
env: { DATABASE_URL: databaseUrl, DIRECT_URL: databaseUrl },
});
}
/**
* Pushes the DEDICATED run-ops subset schema (@internal/run-ops-database) into the database at
* `databaseUrl`. The run-ops datasource reads RUN_OPS_DATABASE_URL, and its schema is a subset of
* the control-plane schema (run-ops tables, no Organization/Project/etc).
*/
export async function pushRunOpsSchema(databaseUrl: string) {
// Resolve the schema (and the package's own prisma binary) through the @internal/run-ops-database
// package so this keeps working regardless of where pnpm hoists it.
const schemaPath = require.resolve("@internal/run-ops-database/prisma/schema.prisma");
const runOpsPackagePath = path.resolve(schemaPath, "../..");
const result = await pushPrismaSchema({
prismaBin: `${runOpsPackagePath}/node_modules/.bin/prisma`,
schemaPath,
env: { RUN_OPS_DATABASE_URL: databaseUrl },
});
// `db push` derives DDL from the schema datamodel and so cannot create the SQL-only partial unique
// index Prisma can't express (the NULL-batchIndex dedup). Apply it here so the test DB matches what
// `migrate deploy` builds in production; otherwise ON CONFLICT DO NOTHING can't dedupe a re-blocked
// NULL-batchIndex edge and the idempotency contract goes untested.
await applyRunOpsSqlOnlyIndexes(databaseUrl);
return result;
}
async function applyRunOpsSqlOnlyIndexes(databaseUrl: string) {
// Reuse @trigger.dev/database's PrismaClient purely as a raw-SQL connection (the established
// primitive in this package — see createDatabaseFromTemplate). No model access, so the subset
// schema mismatch is irrelevant.
const client = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
try {
await client.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "TaskRunWaitpoint_taskRunId_waitpointId_batchIndex_null_key" ON "public"."TaskRunWaitpoint"("taskRunId", "waitpointId") WHERE "batchIndex" IS NULL`
);
} finally {
await client.$disconnect();
}
}
async function pushPrismaSchema({
prismaBin,
schemaPath,
env,
}: {
prismaBin: string;
schemaPath: string;
env: Record<string, string>;
}) {
// throwOnError is essential: without it tinyexec swallows a non-zero `prisma db push`, so a failed
// push looks like success and only surfaces much later as a confusing downstream error.
const result = await x(
prismaBin,
[
"db",
"push",
"--force-reset",
"--accept-data-loss",
"--skip-generate",
"--schema",
schemaPath,
],
{
throwOnError: true,
nodeOptions: {
env: {
...process.env,
...env,
},
},
}
);
return result;
}
/**
* Caps each container's CPU/memory to approximate the 2-core CI runner locally (for timing + flake
* reproduction). Set TESTCONTAINERS_CPU (cores per container, e.g. "2") and/or
* TESTCONTAINERS_MEMORY_GB (GB per container). Pair with running the runner under `taskset -c 0,1`.
* No-op when neither is set. (testcontainers v11 has no cpuset pinning, only this quota cap.)
*/
export function withCiResourceLimits<T extends GenericContainer>(container: T): T {
const cpu = parsePositiveNumberEnv("TESTCONTAINERS_CPU");
const memory = parsePositiveNumberEnv("TESTCONTAINERS_MEMORY_GB");
if (cpu === undefined && memory === undefined) {
return container;
}
return container.withResourcesQuota({
...(cpu !== undefined ? { cpu } : {}),
...(memory !== undefined ? { memory } : {}),
});
}
// Fail fast on a malformed value rather than letting NaN reach the container runtime as a cryptic error.
function parsePositiveNumberEnv(name: string): number | undefined {
const raw = process.env[name];
if (!raw) return undefined;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number, got "${raw}"`);
}
return value;
}
export async function createPostgresContainer(
network: StartedNetwork,
opts?: { imageTag?: string; initdbArgs?: string }
) {
const imageTag = opts?.imageTag ?? "docker.io/postgres:14";
let builder = withCiResourceLimits(new PostgreSqlContainer(imageTag))
.withNetwork(network)
.withNetworkAliases("database")
.withCommand(["-c", "listen_addresses=*", "-c", "wal_level=logical"]);
if (opts?.initdbArgs) {
// POSTGRES_INITDB_ARGS is the official postgres image hook for initdb flags
// (locale/collation provider). No dedicated PostgreSqlContainer method exists.
builder = builder.withEnvironment({ POSTGRES_INITDB_ARGS: opts.initdbArgs });
}
const container = await builder.start();
await pushDatabaseSchema(container.getConnectionUri());
return { url: container.getConnectionUri(), container, network };
}
export async function createClickHouseContainer(network: StartedNetwork) {
const container = await withCiResourceLimits(new ClickHouseContainer())
.withNetwork(network)
.start();
const client = createClient({
url: container.getConnectionUrl(),
});
await client.ping();
const migrationsPath = path.resolve(__dirname, "../../clickhouse/schema");
await runClickhouseMigrations(client, migrationsPath);
return {
url: container.getConnectionUrl(),
container,
network,
};
}
export async function createRedisContainer({
port,
network,
}: {
port?: number;
network?: StartedNetwork;
}) {
let container = withCiResourceLimits(new RedisContainer("redis:7.2"))
.withExposedPorts(port ?? 6379)
.withStartupTimeout(120_000); // 2 minutes
if (network) {
container = container.withNetwork(network).withNetworkAliases("redis");
}
// Wait only on the readiness log (RedisContainer's default) - the previous Docker healthcheck added
// a full poll-cycle of latency per boot, which dominates per-test redis. verifyRedisConnection
// below still confirms the container actually accepts connections before we hand it to the test.
const startedContainer = await container
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
.start();
const [error] = await tryCatch(verifyRedisConnection(startedContainer));
if (error) {
await startedContainer.stop({ timeout: 30_000 });
throw new Error("verifyRedisConnection error", { cause: error });
}
return {
container: startedContainer,
};
}
async function verifyRedisConnection(container: StartedRedisContainer) {
const redis = new Redis({
host: container.getHost(),
port: container.getPort(),
password: container.getPassword(),
maxRetriesPerRequest: 20,
connectTimeout: 10000,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
const containerMetadata = {
containerId: container.getId().slice(0, 12),
containerName: container.getName(),
containerNetworkNames: container.getNetworkNames(),
};
redis.on("error", (error) => {
if (isDebug) {
console.log("verifyRedisConnection: client error", error, containerMetadata);
}
// Don't throw here, we'll do that below if the ping fails
});
try {
await redis.ping();
} catch (error) {
if (isDebug) {
console.log("verifyRedisConnection: ping error", error, containerMetadata);
}
throw new Error("verifyRedisConnection: ping error", { cause: error });
} finally {
await redis.quit();
}
}
export async function createElectricContainer(
postgresContainer: StartedPostgreSqlContainer,
network: StartedNetwork
) {
const databaseUrl = `postgresql://${postgresContainer.getUsername()}:${postgresContainer.getPassword()}@${postgresContainer.getIpAddress(
network.getName()
)}:5432/${postgresContainer.getDatabase()}?sslmode=disable`;
const container = await withCiResourceLimits(
new GenericContainer(
"electricsql/electric:1.2.4@sha256:20da3d0b0e74926c5623392db67fd56698b9e374c4aeb6cb5cadeb8fea171c36"
)
)
.withExposedPorts(3000)
.withNetwork(network)
.withEnvironment({
DATABASE_URL: databaseUrl,
ELECTRIC_INSECURE: "true",
})
.start();
return {
container,
origin: `http://${container.getHost()}:${container.getMappedPort(3000)}`,
};
}
export async function createMinIOContainer(network: StartedNetwork) {
const container = await withCiResourceLimits(new MinIOContainer())
.withNetwork(network)
.withNetworkAliases("minio")
.start();
return {
container,
network,
};
}
export function assertNonNullable<T>(value: T): asserts value is NonNullable<T> {
// Plain throw — *not* `vitest.expect`. Two reasons:
// 1. This module is imported by globalSetup files that run before any
// vitest worker exists, so `import { expect }` from "vitest" at
// top level can crash on init.
// 2. Lazy-loading via `require("vitest")` (the prior fix) collides
// with OTel auto-instrumentation: `@opentelemetry/instrumentation`
// hooks `require()` via `require-in-the-middle`, and vitest is
// ESM-only — the require() throws "Vitest cannot be imported in
// a CommonJS module using require()", failing every test that
// uses `assertNonNullable` after OTel's been touched.
// The plain throw still gives vitest a useful failure (the message is
// shown in the stack trace) without the instrumentation hazard.
if (value === null || value === undefined) {
throw new Error(`assertNonNullable: value was ${value === null ? "null" : "undefined"}`);
}
}
export async function withContainerSetup<T>({
name,
task,
setup,
}: {
name: string;
task: TestContext["task"];
setup: Promise<T extends { container: StartedTestContainer } ? T : never>;
}): Promise<T & { metadata: Record<string, unknown> }> {
const testName = task.name;
logSetup(`${name}: starting`, { testName });
const start = Date.now();
const result = await setup;
const startDurationMs = Date.now() - start;
const metadata = {
...getTaskMetadata(task),
...getContainerMetadata(result.container),
startDurationMs,
};
logSetup(`${name}: started`, metadata);
return { ...result, metadata };
}
export async function useContainer<TContainer extends StartedTestContainer>(
name: string,
{
container,
task,
use,
}: { container: TContainer; task: TestContext["task"]; use: () => Promise<void> }
) {
const metadata = {
...getTaskMetadata(task),
...getContainerMetadata(container),
useDurationMs: 0,
};
try {
const start = Date.now();
await use();
const useDurationMs = Date.now() - start;
metadata.useDurationMs = useDurationMs;
} finally {
// Containers are throwaway, so we force-kill (SIGKILL) instead of waiting for a graceful
// shutdown - ClickHouse alone spends ~5s/test gracefully stopping. timeout: 0 = immediate kill.
// We still await it (no pileup); logCleanup swallows any teardown-time connection errors.
await logCleanup(name, container.stop({ timeout: 0 }), metadata);
}
}
@@ -0,0 +1,253 @@
import { spawn } from "child_process";
import { createServer } from "net";
import { delimiter, resolve } from "path";
import { Network } from "testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { createPostgresContainer, createRedisContainer } from "./utils";
const WEBAPP_ROOT = resolve(__dirname, "../../../apps/webapp");
// pnpm hoists transitive deps to node_modules/.pnpm/node_modules but does NOT symlink them
// to the root node_modules. We need NODE_PATH so the webapp process can find them at runtime.
const PNPM_HOISTED_MODULES = resolve(__dirname, "../../../node_modules/.pnpm/node_modules");
async function findFreePort(): Promise<number> {
return new Promise((res, rej) => {
const srv = createServer();
srv.listen(0, () => {
const port = (srv.address() as { port: number }).port;
srv.close((err) => (err ? rej(err) : res(port)));
});
});
}
async function waitForHealthcheck(
url: string,
opts: { timeoutMs?: number; acceptAnyResponse?: boolean } = {}
): Promise<void> {
const { timeoutMs = 60000, acceptAnyResponse = false } = opts;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (acceptAnyResponse || res.ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Webapp did not become healthy at ${url} within ${timeoutMs}ms`);
}
export interface WebappInstance {
baseUrl: string;
fetch(path: string, init?: RequestInit): Promise<Response>;
}
export interface StartWebappOptions {
/**
* When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1`
* so the default fallback handles all auth checks. The comprehensive
* suite (`*.e2e.full.test.ts`) relies on this — it's pinned to the
* fallback so results don't depend on whether `@triggerdotdev/plugins/rbac`
* happens to be installed in the local node_modules.
*
* Set to false to spawn a webapp that loads any installed RBAC
* plugin instead, for testing the plugin path.
*/
forceRbacFallback?: boolean;
/**
* When true, spawns the webapp with `REQUIRE_PLUGINS=1` so the plugin
* loader throws instead of silently falling back when the plugin
* module fails to load. Used by the healthcheck rollback e2e test —
* with this set and the plugin not installed, `/healthcheck` should
* return 500.
*
* Implies `forceRbacFallback: false` (you can't observe REQUIRE_PLUGINS
* behaviour when the loader is short-circuited).
*/
requirePlugins?: boolean;
}
export async function startWebapp(
databaseUrl: string,
redis: { host: string; port: number },
options: StartWebappOptions = {}
): Promise<{
instance: WebappInstance;
stop: () => Promise<void>;
}> {
// requirePlugins implies forceRbacFallback=false — you can't observe the
// REQUIRE_PLUGINS=1 throw if the loader short-circuits before reaching it.
const requirePlugins = options.requirePlugins ?? false;
const forceRbacFallback = options.forceRbacFallback ?? !requirePlugins;
const port = await findFreePort();
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
const existingNodePath = process.env.NODE_PATH;
const nodePath = existingNodePath
? `${PNPM_HOISTED_MODULES}${delimiter}${existingNodePath}`
: PNPM_HOISTED_MODULES;
const proc = spawn(process.execPath, ["build/server.js"], {
cwd: WEBAPP_ROOT,
env: {
...process.env,
// Match `pnpm run start` (production-mode boot). NODE_ENV=test
// surfaces a circular-init regression in the production bundle
// — see TRI-8731 — that production-mode dodges by initialising
// modules in a different order. Tests don't depend on test-mode
// semantics; they only need an isolated webapp + DB.
NODE_ENV: "production",
DATABASE_URL: databaseUrl,
DIRECT_URL: databaseUrl,
PORT: String(port),
REMIX_APP_PORT: String(port), // override .env file value (vitest loads .env via Vite)
SESSION_SECRET: "test-session-secret-for-e2e-tests",
MAGIC_LINK_SECRET: "test-magic-link-secret-32chars!!",
ENCRYPTION_KEY: "test-encryption-key-for-e2e!!!!!", // exactly 32 bytes
CLICKHOUSE_URL: "http://localhost:19123", // dummy, auth paths never connect
DEPLOY_REGISTRY_HOST: "registry.example.com", // dummy, not needed for auth tests
ELECTRIC_ORIGIN: "http://localhost:3060",
REDIS_HOST: redis.host,
REDIS_PORT: String(redis.port),
REDIS_TLS_DISABLED: "true", // all *_REDIS_TLS_DISABLED vars default to this; test Redis has no TLS
// Disable all background workers. Each worker has its own env var and its own
// check idiom ("0" vs "false" vs boolean), so we set all of them explicitly.
WORKER_ENABLED: "false", // disables workerQueue.initialize() (checked === "true")
RUN_ENGINE_WORKER_ENABLED: "0", // disables run engine workers (checked === "0", default "1")
SCHEDULE_WORKER_ENABLED: "0", // disables schedule engine worker (checked === "0")
BATCH_QUEUE_WORKER_ENABLED: "false", // disables batch queue consumers (BoolEnv)
LEGACY_RUN_ENGINE_WORKER_ENABLED: "0", // disables legacy run engine worker
COMMON_WORKER_ENABLED: "0", // disables common worker
RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv)
RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv)
RUN_REPLICATION_ENABLED: "0",
// Force the RBAC loader to use the default fallback in e2e tests
// so auth behaviour is deterministic regardless of whether a
// plugin is installed in the local node_modules. Set to "0" /
// undefined to spawn a webapp that loads any installed plugin.
...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}),
// When requirePlugins is set, explicitly override RBAC_FORCE_FALLBACK
// to "0" so a local apps/webapp/.env that sets it to "1" doesn't
// short-circuit the loader past the REQUIRE_PLUGINS check.
...(requirePlugins ? { REQUIRE_PLUGINS: "1", RBAC_FORCE_FALLBACK: "0" } : {}),
NODE_PATH: nodePath,
},
stdio: ["ignore", "pipe", "pipe"],
});
const stderr: string[] = [];
proc.stderr?.on("data", (d: Buffer) => {
const line = d.toString();
stderr.push(line);
if (process.env.WEBAPP_TEST_VERBOSE) {
process.stderr.write(line);
}
});
const stdout: string[] = [];
proc.stdout?.on("data", (d: Buffer) => {
const line = d.toString();
stdout.push(line);
if (process.env.WEBAPP_TEST_VERBOSE) {
process.stdout.write(line);
}
});
// Capture spawn errors instead of throwing from inside the listener.
// A synchronous throw from an EventEmitter listener bypasses the surrounding
// try/catch and surfaces as an uncaughtException, tearing down the test runner.
let spawnError: Error | undefined;
proc.on("error", (err) => {
spawnError = err;
});
const baseUrl = `http://localhost:${port}`;
try {
if (spawnError) throw spawnError;
// When requirePlugins is set, /healthcheck is expected to respond with
// 500 (the whole point of those tests is to verify that). Accept any
// response as "the webapp is up" — the test then asserts the status.
await waitForHealthcheck(`${baseUrl}/healthcheck`, {
acceptAnyResponse: requirePlugins,
});
if (spawnError) throw spawnError;
} catch (err) {
proc.kill("SIGTERM");
const output = [...stdout, ...stderr].join("\n");
throw new Error(`Webapp failed to start.\nOutput:\n${output}\n\nOriginal error: ${err}`);
}
return {
instance: {
baseUrl,
fetch: (path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, init),
},
stop: () =>
new Promise<void>((res) => {
const timer = setTimeout(() => {
proc.kill("SIGKILL");
res();
}, 10_000);
proc.once("exit", () => {
clearTimeout(timer);
res();
});
proc.kill("SIGTERM");
}),
};
}
export interface TestServer {
webapp: WebappInstance;
prisma: PrismaClient;
// Postgres connection string. Useful when test workers run in separate
// processes and need to construct their own clients against the same DB.
databaseUrl: string;
stop: () => Promise<void>;
}
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
export async function startTestServer(options: StartWebappOptions = {}): Promise<TestServer> {
const network = await new Network().start();
// Track each resource as we acquire it so we can tear it down if a later step fails.
let pgContainer: Awaited<ReturnType<typeof createPostgresContainer>>["container"] | undefined;
let pgUrl: string | undefined;
let redisContainer: Awaited<ReturnType<typeof createRedisContainer>>["container"] | undefined;
let prisma: PrismaClient | undefined;
let stopWebapp: (() => Promise<void>) | undefined;
let webapp: WebappInstance;
try {
const pg = await createPostgresContainer(network);
pgContainer = pg.container;
pgUrl = pg.url;
const { container: rc } = await createRedisContainer({ network });
redisContainer = rc;
prisma = new PrismaClient({ datasources: { db: { url: pg.url } } });
await prisma.$connect(); // pre-warm pool; surface connection failures before tests start
const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() }, options);
webapp = started.instance;
stopWebapp = started.stop;
} catch (err) {
await stopWebapp?.().catch(() => {});
await prisma?.$disconnect().catch(() => {});
await pgContainer?.stop().catch(() => {});
await redisContainer?.stop().catch(() => {});
await network.stop().catch(() => {});
throw err;
}
const stop = async () => {
await stopWebapp!().catch((err) => console.error("stopWebapp failed:", err));
await prisma!.$disconnect().catch((err) => console.error("prisma.$disconnect failed:", err));
await pgContainer!.stop().catch((err) => console.error("pgContainer.stop failed:", err));
await redisContainer!.stop().catch((err) => console.error("redisContainer.stop failed:", err));
await network.stop().catch((err) => console.error("network.stop failed:", err));
};
return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop };
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "CommonJS",
"moduleResolution": "Node",
"moduleDetection": "force",
"types": ["vitest/globals"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"],
"@trigger.dev/database": ["../../internal-packages/database/src/index"],
"@trigger.dev/database/*": ["../../internal-packages/database/src/*"]
}
},
"exclude": ["node_modules"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["**/*.test.ts"],
globals: true,
},
});