* Make self-host tests CI-safe * Await cloud db teardown * Give local e2e boots more headroom * Keep main CI runs alive * Cache CI dependencies and image layers * Wait for recorded introspection requests in graphql plugin tests * Size self-host test budget for loaded runners * Probe test http servers for readiness before use * Die on recorder wait timeout instead of typing a string error * Type the recorder wait timeout as a tagged error * Update e2e locators for the integration copy renames * Keep stale data rendered while a refresh is waiting * Guard create-org input against hydration clobbering * Match the new sign-in heading in the selfhost callback test
Showing the changes to apps (8 of 27 files). Show all
Unified · Split
| @@ -73,16 +73,12 @@ const makePostgresResource = (): DbResource => { · 72 unmodified lines | |||
| 73 | sql, | 73 | sql, |
| 74 | db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb, | 74 | db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb, |
| 75 | close: () => | 75 | close: () => |
| 76 | Effect.sync(() => { | 76 | Effect.ignore( |
| 77 | void Effect.runFork( | 77 | Effect.tryPromise({ |
| 78 | Effect.ignore( | 78 | try: () => sql.end({ timeout: 0 }), |
| 79 | Effect.tryPromise({ | 79 | catch: (cause) => cause, |
| 80 | try: () => sql.end({ timeout: 0 }), | 80 | }), |
| 81 | catch: (cause) => cause, | 81 | ), |
| 82 | }), | ||
| 83 | ), | ||
| 84 | ); | ||
| 85 | }), | ||
| 86 | }; | 82 | }; |
| 87 | }; | 83 | }; |
| 88 | 84 | ||
| @@ -2,20 +2,26 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { afterAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 6 | 6 | ||
| 7 | // Config reads the environment, so point it at a throwaway data dir before | 7 | // Config reads the environment, so point it at a throwaway data dir before |
| 8 | // importing the app graph. | 8 | // importing the app graph. |
| 9 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-boot-")); | 9 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-boot-")); |
| 10 | 10 | ||
| 11 | const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); | 11 | let handler!: (request: Request) => Promise<Response>; |
| 12 | let dispose: () => Promise<void> = async () => {}; | ||
| 12 | 13 | ||
| 13 | const { handler, dispose } = await makeSelfHostTestApp({ | 14 | beforeAll(async () => { |
| 14 | identity: singleAdminIdentityLayer({ | 15 | const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); |
| 15 | userId: "admin", | 16 | const app = await makeSelfHostTestApp({ |
| 16 | organizationId: "default-org", | 17 | identity: singleAdminIdentityLayer({ |
| 17 | organizationName: "Default", | 18 | userId: "admin", |
| 18 | }), | 19 | organizationId: "default-org", |
| 20 | organizationName: "Default", | ||
| 21 | }), | ||
| 22 | }); | ||
| 23 | handler = app.handler; | ||
| 24 | dispose = app.dispose; | ||
| 19 | }); | 25 | }); |
| 20 | afterAll(() => dispose()); | 26 | afterAll(() => dispose()); |
| 21 | 27 | ||
| @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { afterAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 6 | import { connectionIdentifier } from "@executor-js/sdk/shared"; | 6 | import { connectionIdentifier } from "@executor-js/sdk/shared"; |
| 7 | 7 | ||
| 8 | import { mintInviteCode } from "./testing/mint-invite"; | 8 | import { mintInviteCode } from "./testing/mint-invite"; |
| @@ -13,9 +13,15 @@ process.env.BETTER_AUTH_SECRET = "multi-user-secret-0123456789-abcdefghij-klmn"; · 4 unmodified lines | |||
| 13 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@multi.test"; | 13 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@multi.test"; |
| 14 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; | 14 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; |
| 15 | 15 | ||
| 16 | const { makeSelfHostApiHandler } = await import("./app"); | 16 | let handler!: (request: Request) => Promise<Response>; |
| 17 | let dispose: () => Promise<void> = async () => {}; | ||
| 17 | 18 | ||
| 18 | const { handler, dispose } = await makeSelfHostApiHandler(); | 19 | beforeAll(async () => { |
| 20 | const { makeSelfHostApiHandler } = await import("./app"); | ||
| 21 | const app = await makeSelfHostApiHandler(); | ||
| 22 | handler = app.handler; | ||
| 23 | dispose = app.dispose; | ||
| 24 | }); | ||
| 19 | afterAll(() => dispose()); | 25 | afterAll(() => dispose()); |
| 20 | 26 | ||
| 21 | const BASE = "http://localhost:4788"; | 27 | const BASE = "http://localhost:4788"; |
| @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { afterAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 6 | import { connectionIdentifier } from "@executor-js/sdk/shared"; | 6 | import { connectionIdentifier } from "@executor-js/sdk/shared"; |
| 7 | 7 | ||
| 8 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-")); | 8 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-")); |
| @@ -13,10 +13,16 @@ process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-")); · 4 unmodified lines | |||
| 13 | // request-scoped. Each identity is its own (org, user): in v2 the org is the | 13 | // request-scoped. Each identity is its own (org, user): in v2 the org is the |
| 14 | // tenant (catalog partition) and the user is the acting subject (drives | 14 | // tenant (catalog partition) and the user is the acting subject (drives |
| 15 | // `owner: "user"` rows). | 15 | // `owner: "user"` rows). |
| 16 | const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); | 16 | let handler!: (request: Request) => Promise<Response>; |
| 17 | let dispose: () => Promise<void> = async () => {}; | ||
| 17 | 18 | ||
| 18 | const { handler, dispose } = await makeSelfHostTestApp({ | 19 | beforeAll(async () => { |
| 19 | identity: headerIdentityLayer, | 20 | const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); |
| 21 | const app = await makeSelfHostTestApp({ | ||
| 22 | identity: headerIdentityLayer, | ||
| 23 | }); | ||
| 24 | handler = app.handler; | ||
| 25 | dispose = app.dispose; | ||
| 20 | }); | 26 | }); |
| 21 | afterAll(() => dispose()); | 27 | afterAll(() => dispose()); |
| 22 | 28 | ||
| @@ -135,7 +141,7 @@ test("concurrent requests with distinct identities get disjoint, correct executo · 112 unmodified lines | |||
| 135 | expect(addresses.some((a) => a.includes(connectionNameForUser(other)))).toBe(false); | 141 | expect(addresses.some((a) => a.includes(connectionNameForUser(other)))).toBe(false); |
| 136 | } | 142 | } |
| 137 | }); | 143 | }); |
| 138 | }, 30_000); | 144 | }); |
| 139 | 145 | ||
| 140 | test("a request with no identity is rejected", async () => { | 146 | test("a request with no identity is rejected", async () => { |
| 141 | const res = await handler(new Request("http://localhost/api/connections")); | 147 | const res = await handler(new Request("http://localhost/api/connections")); |
| @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; · 3 unmodified lines | |||
| 4 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 5 | 5 | ||
| 6 | import { Effect, Layer } from "effect"; | 6 | import { Effect, Layer } from "effect"; |
| 7 | import { afterAll, expect, test } from "@effect/vitest"; | 7 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 8 | 8 | ||
| 9 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; | 9 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; |
| 10 | import { makeScopedExecutor } from "@executor-js/api/server"; | 10 | import { makeScopedExecutor } from "@executor-js/api/server"; |
| @@ -32,13 +32,20 @@ const createScopedExecutor = ( · 21 unmodified lines | |||
| 32 | Effect.provide(SelfHostScopedExecutorSeams), | 32 | Effect.provide(SelfHostScopedExecutorSeams), |
| 33 | ); | 33 | ); |
| 34 | 34 | ||
| 35 | const dbHandle = await createSelfHostDb({ | 35 | let dbLayer!: Layer.Layer<SelfHostDb>; |
| 36 | path: dbPath, | 36 | let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; |
| 37 | namespace: "executor_selfhost", | 37 | |
| 38 | version: "1.0.0", | 38 | beforeAll(async () => { |
| 39 | dbHandle = await createSelfHostDb({ | ||
| 40 | path: dbPath, | ||
| 41 | namespace: "executor_selfhost", | ||
| 42 | version: "1.0.0", | ||
| 43 | }); | ||
| 44 | dbLayer = Layer.succeed(SelfHostDb)(dbHandle); | ||
| 45 | }); | ||
| 46 | afterAll(async () => { | ||
| 47 | await dbHandle?.close(); | ||
| 39 | }); | 48 | }); |
| 40 | const dbLayer = Layer.succeed(SelfHostDb)(dbHandle); | ||
| 41 | afterAll(() => dbHandle.close()); | ||
| 42 | 49 | ||
| 43 | const TINY_SPEC = JSON.stringify({ | 50 | const TINY_SPEC = JSON.stringify({ |
| 44 | openapi: "3.0.0", | 51 | openapi: "3.0.0", |
| @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | |||
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { Effect, Layer } from "effect"; | 5 | import { Effect, Layer } from "effect"; |
| 6 | import { afterAll, expect, test } from "@effect/vitest"; | 6 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 7 | 7 | ||
| 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; | 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; |
| 9 | import { makeScopedExecutor } from "@executor-js/api/server"; | 9 | import { makeScopedExecutor } from "@executor-js/api/server"; |
| @@ -50,8 +50,15 @@ const TINY_SPEC = JSON.stringify({ · 40 unmodified lines | |||
| 50 | }, | 50 | }, |
| 51 | }); | 51 | }); |
| 52 | 52 | ||
| 53 | const { makeSelfHostApiHandler } = await import("./app"); | 53 | let handler!: (request: Request) => Promise<Response>; |
| 54 | const { handler, dispose } = await makeSelfHostApiHandler({ dbPath }); | 54 | let dispose: () => Promise<void> = async () => {}; |
| 55 | |||
| 56 | beforeAll(async () => { | ||
| 57 | const { makeSelfHostApiHandler } = await import("./app"); | ||
| 58 | const app = await makeSelfHostApiHandler({ dbPath }); | ||
| 59 | handler = app.handler; | ||
| 60 | dispose = app.dispose; | ||
| 61 | }); | ||
| 55 | afterAll(() => dispose()); | 62 | afterAll(() => dispose()); |
| 56 | 63 | ||
| 57 | const BASE = "http://localhost:4788"; | 64 | const BASE = "http://localhost:4788"; |
| @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | |||
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { Effect, Layer } from "effect"; | 5 | import { Effect, Layer } from "effect"; |
| 6 | import { afterAll, expect, test } from "@effect/vitest"; | 6 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 7 | 7 | ||
| 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; | 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; |
| 9 | import { makeScopedExecutor } from "@executor-js/api/server"; | 9 | import { makeScopedExecutor } from "@executor-js/api/server"; |
| @@ -28,13 +28,20 @@ const createScopedExecutor = ( · 18 unmodified lines | |||
| 28 | const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); | 28 | const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); |
| 29 | process.env.EXECUTOR_DATA_DIR = dataDir; | 29 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| 30 | 30 | ||
| 31 | const dbHandle = await createSelfHostDb({ | 31 | let dbLayer!: Layer.Layer<SelfHostDb>; |
| 32 | path: join(dataDir, "data.db"), | 32 | let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; |
| 33 | namespace: "executor_selfhost", | 33 | |
| 34 | version: "1.0.0", | 34 | beforeAll(async () => { |
| 35 | dbHandle = await createSelfHostDb({ | ||
| 36 | path: join(dataDir, "data.db"), | ||
| 37 | namespace: "executor_selfhost", | ||
| 38 | version: "1.0.0", | ||
| 39 | }); | ||
| 40 | dbLayer = Layer.succeed(SelfHostDb)(dbHandle); | ||
| 41 | }); | ||
| 42 | afterAll(async () => { | ||
| 43 | await dbHandle?.close(); | ||
| 35 | }); | 44 | }); |
| 36 | const dbLayer = Layer.succeed(SelfHostDb)(dbHandle); | ||
| 37 | afterAll(() => dbHandle.close()); | ||
| 38 | 45 | ||
| 39 | // Inline OpenAPI spec so the test doesn't depend on the network to register. | 46 | // Inline OpenAPI spec so the test doesn't depend on the network to register. |
| 40 | const TINY_SPEC = JSON.stringify({ | 47 | const TINY_SPEC = JSON.stringify({ |
| @@ -4,30 +4,18 @@ export default defineConfig({ · 3 unmodified lines | |||
| 4 | test: { | 4 | test: { |
| 5 | include: ["src/**/*.test.ts"], | 5 | include: ["src/**/*.test.ts"], |
| 6 | passWithNoTests: true, | 6 | passWithNoTests: true, |
| 7 | // These are integration suites: most boot a full self-host app graph | 7 | // These are integration suites: several files boot a full self-host app |
| 8 | // (Better Auth + libSQL + MCP + plugins) at module load, then drive it | 8 | // graph (Better Auth + libSQL + MCP + plugins) in beforeAll, then drive it |
| 9 | // over the in-memory handler, and some (scope-isolation) fire dozens of | 9 | // over the in-memory handler. Each boot is CPU-heavy and every query |
| 10 | // concurrent requests through it. Each boot is CPU-heavy and every query | 10 | // serializes through the one shared libSQL connection, so parallel files can |
| 11 | // serializes through the one shared libSQL connection, so when many files | 11 | // oversubscribe CI and starve in-flight requests. Run files serially and |
| 12 | // boot at once they oversubscribe the CPU and starve the event loop: an | 12 | // give lifecycle hooks room for the cold graph boot. Tests that fan out |
| 13 | // in-flight request stalls indefinitely and the test times out. Which | 13 | // many concurrent requests (scope-isolation) stretch well past 30s when |
| 14 | // files lose the race is nondeterministic, which is the CI flakiness. | 14 | // sibling turbo tasks contend for the same cores, so the budget is sized |
| 15 | // | 15 | // for a loaded runner, not an idle one. |
| 16 | // Vitest sizes its fork pool to the core count, so a many-core machine | 16 | fileParallelism: false, |
| 17 | // oversubscribes HARDER than a small CI runner. Bound the concurrency to | 17 | maxWorkers: 1, |
| 18 | // an absolute number instead, so behavior is the same everywhere: at most | 18 | testTimeout: 120_000, |
| 19 | // two heavy boots run together, which fits a 4-vCPU runner without | 19 | hookTimeout: 60_000, |
| 20 | // starvation while still running the suite in parallel. Pair it with | ||
| 21 | // integration-grade timeouts as headroom for the slowest boot. This keeps | ||
| 22 | // every assertion intact (no logic or coverage changed) and makes the | ||
| 23 | // suite deterministic rather than load-dependent. | ||
| 24 | testTimeout: 30_000, | ||
| 25 | hookTimeout: 30_000, | ||
| 26 | poolOptions: { | ||
| 27 | forks: { | ||
| 28 | maxForks: 2, | ||
| 29 | minForks: 1, | ||
| 30 | }, | ||
| 31 | }, | ||
| 32 | }, | 20 | }, |
| 33 | }); | 21 | }); |