From 935e9e1b5824d0f545d3661db8a8cca66cc9c49c Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:59:31 +0100 Subject: [PATCH 1/3] test(testcontainers): hoist container boot off the test timer Vitest bills fixture setup to the first test's testTimeout - the fixture chain runs inside the test timer - so the one-off worker container boot landed on whichever test resolved it first and consumed a budget sized for test work. On fork PRs, which get no secrets and so skip the CI image pre-pull, that added ~10s and pushed five webapp shards past their 60s cap. Internal runs cleared it by under 10s, so it was a latent flake there too. Registering the boot as a beforeAll with its own timeout moves it off the test clock. Registration is lazy, so only files that actually touch a fixture family pay for it, and happens once per file since isolate gives each file a fresh module registry. --- .../test/containerFixtureWarmup.test.ts | 18 ++ internal-packages/testcontainers/src/index.ts | 176 +++++++++++++----- 2 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 apps/webapp/test/containerFixtureWarmup.test.ts diff --git a/apps/webapp/test/containerFixtureWarmup.test.ts b/apps/webapp/test/containerFixtureWarmup.test.ts new file mode 100644 index 00000000000..e07f435254a --- /dev/null +++ b/apps/webapp/test/containerFixtureWarmup.test.ts @@ -0,0 +1,18 @@ +import { containerTest } from "@internal/testcontainers"; +import { describe, expect, vi } from "vitest"; + +vi.setConfig({ testTimeout: 10_000 }); + +describe("container fixture warmup", () => { + containerTest("the first test is not billed for the container boot", async ({ prisma }) => { + const rows = await prisma.$queryRawUnsafe>("SELECT 1 as ok"); + + expect(rows[0]?.ok).toBe(1); + }); + + containerTest("later tests still get a working fixture", async ({ prisma }) => { + const rows = await prisma.$queryRawUnsafe>("SELECT 2 as ok"); + + expect(rows[0]?.ok).toBe(2); + }); +}); diff --git a/internal-packages/testcontainers/src/index.ts b/internal-packages/testcontainers/src/index.ts index cceadf6cb45..52d33442896 100644 --- a/internal-packages/testcontainers/src/index.ts +++ b/internal-packages/testcontainers/src/index.ts @@ -314,10 +314,50 @@ const prismaFromContainer = async ( } }; -export const postgresTest = test.extend({ - postgresContainer: clonedPostgresContainer, - prisma: prismaFromContainer, -}); +const CONTAINER_WARMUP_TIMEOUT_MS = 300_000; + +type WarmableTestApi = { + beforeAll: (fn: (context: any) => Promise, timeout?: number) => void; +}; + +const withWarmup = ( + api: T, + warmUp: (context: any) => Promise +): T => { + let registered = false; + + const register = () => { + if (registered) { + return; + } + registered = true; + api.beforeAll(warmUp, CONTAINER_WARMUP_TIMEOUT_MS); + }; + + return new Proxy(api, { + apply(target, thisArg, args) { + register(); + return Reflect.apply(target as unknown as (...a: unknown[]) => unknown, thisArg, args); + }, + get(target, prop, receiver) { + if (prop !== "then") { + // awaiting the module is not use + register(); + } + return Reflect.get(target, prop, receiver); + }, + }) as T; +}; + +export const postgresTest = withWarmup( + test.extend({ + postgresContainer: clonedPostgresContainer, + prisma: prismaFromContainer, + }), + async () => { + await getWorkerPostgresContainer(); + } +); type HeteroPostgresTestContext = { // PG14 (legacy / control-plane DB analog) @@ -609,11 +649,16 @@ type RedisTestContext = { // 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({ - redisContainer: [bootWorkerRedis, { scope: "worker" }], - resetRedis: [flushRedis, { auto: true }], - redisOptions, -}); +export const redisTest = withWarmup( + test.extend({ + redisContainer: [bootWorkerRedis, { scope: "worker" }], + resetRedis: [flushRedis, { auto: true }], + redisOptions, + }), + async ({ redisContainer }) => { + void redisContainer; + } +); // 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 @@ -723,11 +768,16 @@ const scopedClickhouseClient = async ( } }; -export const clickhouseTest = test.extend({ - clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }], - resetClickhouse: [truncateClickhouseFixture, { auto: true }], - clickhouseClient: scopedClickhouseClient, -}); +export const clickhouseTest = withWarmup( + test.extend({ + clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }], + resetClickhouse: [truncateClickhouseFixture, { auto: true }], + clickhouseClient: scopedClickhouseClient, + }), + async ({ clickhouseContainer }) => { + void clickhouseContainer; + } +); // 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 + @@ -755,17 +805,24 @@ type ContainerTestContext = { // 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({ - 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, -}); +export const containerTest = withWarmup( + test.extend({ + 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, + }), + async ({ redisContainer, clickhouseContainer }) => { + void redisContainer; + void clickhouseContainer; + await getWorkerPostgresContainer(); + } +); type ContainerWithIsolatedRedisContext = { network: StartedNetwork; @@ -780,16 +837,22 @@ type ContainerWithIsolatedRedisContext = { // 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({ - network, - postgresContainer: clonedPostgresContainer, - prisma: prismaFromContainer, - redisContainer, - redisOptions, - clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }], - resetClickhouse: [truncateClickhouseFixture, { auto: true }], - clickhouseClient: scopedClickhouseClient, -}); +export const containerTestWithIsolatedRedis = withWarmup( + test.extend({ + network, + postgresContainer: clonedPostgresContainer, + prisma: prismaFromContainer, + redisContainer, + redisOptions, + clickhouseContainer: [bootWorkerClickhouse, { scope: "worker" }], + resetClickhouse: [truncateClickhouseFixture, { auto: true }], + clickhouseClient: scopedClickhouseClient, + }), + async ({ clickhouseContainer }) => { + void clickhouseContainer; + await getWorkerPostgresContainer(); + } +); type ContainerWithIsolatedRedisNoClickhouseContext = { network: StartedNetwork; @@ -801,14 +864,18 @@ type ContainerWithIsolatedRedisNoClickhouseContext = { // 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 = +export const containerTestWithIsolatedRedisNoClickhouse = withWarmup( test.extend({ network, postgresContainer: clonedPostgresContainer, prisma: prismaFromContainer, redisContainer, redisOptions, - }); + }), + async () => { + await getWorkerPostgresContainer(); + } +); // 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 + @@ -887,11 +954,16 @@ type MinioTestContext = { minioConfig: MinIOConnectionConfig; }; -export const minioTest = test.extend({ - minioContainer: [bootWorkerMinio, { scope: "worker" }], - resetMinio: [minioReset, { auto: true }], - minioConfig, -}); +export const minioTest = withWarmup( + test.extend({ + minioContainer: [bootWorkerMinio, { scope: "worker" }], + resetMinio: [minioReset, { auto: true }], + minioConfig, + }), + async ({ minioContainer }) => { + void minioContainer; + } +); type PostgresAndMinioTestContext = { postgresContainer: StartedPostgreSqlContainer; @@ -901,10 +973,16 @@ type PostgresAndMinioTestContext = { minioConfig: MinIOConnectionConfig; }; -export const postgresAndMinioTest = test.extend({ - postgresContainer: clonedPostgresContainer, - prisma: prismaFromContainer, - minioContainer: [bootWorkerMinio, { scope: "worker" }], - resetMinio: [minioReset, { auto: true }], - minioConfig, -}); +export const postgresAndMinioTest = withWarmup( + test.extend({ + postgresContainer: clonedPostgresContainer, + prisma: prismaFromContainer, + minioContainer: [bootWorkerMinio, { scope: "worker" }], + resetMinio: [minioReset, { auto: true }], + minioConfig, + }), + async ({ minioContainer }) => { + void minioContainer; + await getWorkerPostgresContainer(); + } +); From 8aa567029c996742c0969281f24e3b2e0497631d Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:04:26 +0100 Subject: [PATCH 2/3] test(testcontainers): run this package's tests in CI --- internal-packages/testcontainers/package.json | 3 ++- .../testcontainers/src/warmup.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename apps/webapp/test/containerFixtureWarmup.test.ts => internal-packages/testcontainers/src/warmup.test.ts (90%) diff --git a/internal-packages/testcontainers/package.json b/internal-packages/testcontainers/package.json index c1e68946aa6..b42f05863bc 100644 --- a/internal-packages/testcontainers/package.json +++ b/internal-packages/testcontainers/package.json @@ -26,6 +26,7 @@ "tinyexec": "^0.3.0" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest --sequence.concurrent=false --no-file-parallelism" } } diff --git a/apps/webapp/test/containerFixtureWarmup.test.ts b/internal-packages/testcontainers/src/warmup.test.ts similarity index 90% rename from apps/webapp/test/containerFixtureWarmup.test.ts rename to internal-packages/testcontainers/src/warmup.test.ts index e07f435254a..de234cda00a 100644 --- a/apps/webapp/test/containerFixtureWarmup.test.ts +++ b/internal-packages/testcontainers/src/warmup.test.ts @@ -1,5 +1,5 @@ -import { containerTest } from "@internal/testcontainers"; import { describe, expect, vi } from "vitest"; +import { containerTest } from "./index"; vi.setConfig({ testTimeout: 10_000 }); From cca8aea1bd0b9dc91c5e7b0d7b0378f083e85f08 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:33:49 +0100 Subject: [PATCH 3/3] test(testcontainers): register the warmup per collecting suite --- internal-packages/testcontainers/src/index.ts | 6 ------ .../testcontainers/src/warmup.test.ts | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/internal-packages/testcontainers/src/index.ts b/internal-packages/testcontainers/src/index.ts index 52d33442896..820ca827aec 100644 --- a/internal-packages/testcontainers/src/index.ts +++ b/internal-packages/testcontainers/src/index.ts @@ -324,13 +324,7 @@ const withWarmup = ( api: T, warmUp: (context: any) => Promise ): T => { - let registered = false; - const register = () => { - if (registered) { - return; - } - registered = true; api.beforeAll(warmUp, CONTAINER_WARMUP_TIMEOUT_MS); }; diff --git a/internal-packages/testcontainers/src/warmup.test.ts b/internal-packages/testcontainers/src/warmup.test.ts index de234cda00a..b0382dcd024 100644 --- a/internal-packages/testcontainers/src/warmup.test.ts +++ b/internal-packages/testcontainers/src/warmup.test.ts @@ -1,8 +1,14 @@ import { describe, expect, vi } from "vitest"; -import { containerTest } from "./index"; +import { clickhouseTest, containerTest } from "./index"; vi.setConfig({ testTimeout: 10_000 }); +describe.skip("a skipped suite that touches the fixture first", () => { + containerTest("never runs", async ({ prisma }) => { + expect(prisma).toBeDefined(); + }); +}); + describe("container fixture warmup", () => { containerTest("the first test is not billed for the container boot", async ({ prisma }) => { const rows = await prisma.$queryRawUnsafe>("SELECT 1 as ok"); @@ -16,3 +22,12 @@ describe("container fixture warmup", () => { expect(rows[0]?.ok).toBe(2); }); }); + +describe("worker-scoped fixtures are warmed too", () => { + clickhouseTest("clickhouse is up before the first test", async ({ clickhouseClient }) => { + const rs = await clickhouseClient.query({ query: "SELECT 1 AS ok", format: "JSONEachRow" }); + const rows = await rs.json<{ ok: number }>(); + + expect(rows[0]?.ok).toBe(1); + }); +});