History

Make the test suite parallel-safe and speed up CI (#1273)

* 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

Rhys Sullivan committed 4 months ago

Commit 8652c991 parent 9c7e4a627 files · +252 −102Browse files

Unified · Split

27 files
  1. .github/workflows/ci.yml +74 −2
  2. apps/cloud/src/db/db.ts +6 −10
  3. apps/host-selfhost/src/boot.test.ts +14 −8
  4. apps/host-selfhost/src/multi-user.test.ts +9 −3
  5. apps/host-selfhost/src/scope-isolation.test.ts +11 −5
  6. apps/host-selfhost/src/secrets-integration.test.ts +14 −7
  7. apps/host-selfhost/src/sources-mcp.test.ts +10 −3
  8. apps/host-selfhost/src/sources.test.ts +14 −7
  9. apps/host-selfhost/vitest.config.ts +13 −25
  10. e2e/cloud/auth-routing-flow.test.ts +11 −1
  11. e2e/local/local-server.ts +4 −4
  12. e2e/local/stdio-mcp.test.ts +2 −2
  13. e2e/scenarios/auth-methods-ui.test.ts +2 −2
  14. e2e/scenarios/google-photos-preset-ui.test.ts +1 −1
  15. e2e/scenarios/mcp-catalog-sync-ui.test.ts +2 −2
  16. e2e/scenarios/provider-plugins-ui.test.ts +2 −2
  17. e2e/selfhost/auth-methods-ui.test.ts +2 −2
  18. e2e/selfhost/mcp-auth-required-add.test.ts +2 −2
  19. e2e/selfhost/oauth-callback-unauthenticated.test.ts +1 −1
  20. e2e/selfhost/posthog-mcp-oauth.test.ts +2 −2
  21. e2e/vitest.config.ts +1 −1
  22. package.json +1 −1
  23. packages/core/sdk/src/testing.ts +12 −1
  24. packages/plugins/graphql/src/sdk/plugin.test.ts +6 −5
  25. packages/plugins/graphql/src/testing/index.ts +23 −0
  26. packages/react/src/lib/async-result.test.ts +8 −2
  27. packages/react/src/lib/async-result.ts +5 −1
.github/workflows/ci.yml+74−2
@@ -7,8 +7,8 @@ on: · 6 unmodified lines
77 - main
88
99 concurrency:
10− group: ci-${{ github.ref }}
11− cancel-in-progress: true
10+ group: ci-${{ github.event_name == 'push' && format('{0}-{1}', github.ref, github.sha) || github.ref }}
11+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
1212
1313 jobs:
1414 format:
@@ -21,6 +21,14 @@ jobs: · 6 unmodified lines
2121 with:
2222 bun-version: 1.3.11
2323
24+ - name: Cache Bun package cache
25+ uses: actions/cache@v4
26+ with:
27+ path: ~/.bun/install/cache
28+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
29+ restore-keys: |
30+ ${{ runner.os }}-bun-1.3.11-
31+
2432 - run: bun install --frozen-lockfile
2533
2634 - run: bun run format:check
@@ -35,6 +43,14 @@ jobs: · 8 unmodified lines
3543 with:
3644 bun-version: 1.3.11
3745
46+ - name: Cache Bun package cache
47+ uses: actions/cache@v4
48+ with:
49+ path: ~/.bun/install/cache
50+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
51+ restore-keys: |
52+ ${{ runner.os }}-bun-1.3.11-
53+
3854 - run: bun install --frozen-lockfile
3955
4056 - run: bun run lint
@@ -49,6 +65,14 @@ jobs: · 8 unmodified lines
4965 with:
5066 bun-version: 1.3.11
5167
68+ - name: Cache Bun package cache
69+ uses: actions/cache@v4
70+ with:
71+ path: ~/.bun/install/cache
72+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
73+ restore-keys: |
74+ ${{ runner.os }}-bun-1.3.11-
75+
5276 - run: bun install --frozen-lockfile
5377
5478 - run: bun run typecheck
@@ -56,6 +80,8 @@ jobs: · 1 unmodified line
5680 test:
5781 name: Test
5882 runs-on: ubuntu-latest
83+ env:
84+ TURBO_TEST_CONCURRENCY: 3
5985 steps:
6086 - uses: actions/checkout@v4
6187
@@ -63,6 +89,14 @@ jobs: · 1 unmodified line
6389 with:
6490 bun-version: 1.3.11
6591
92+ - name: Cache Bun package cache
93+ uses: actions/cache@v4
94+ with:
95+ path: ~/.bun/install/cache
96+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
97+ restore-keys: |
98+ ${{ runner.os }}-bun-1.3.11-
99+
66100 # apps/cloud's test script invokes `node` directly; undici 8.x (pulled
67101 # in by @cloudflare/vitest-pool-workers) calls webidl.markAsUncloneable
68102 # which only exists in Node 22.10+. Pin a known-good runtime.
@@ -105,6 +139,14 @@ jobs: · 36 unmodified lines
105139 with:
106140 bun-version: 1.3.11
107141
142+ - name: Cache Bun package cache
143+ uses: actions/cache@v4
144+ with:
145+ path: ~/.bun/install/cache
146+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
147+ restore-keys: |
148+ ${{ runner.os }}-bun-1.3.11-
149+
108150 # The dev stacks spawn Node sidecars (vite/workerd tooling); pin the
109151 # same known-good runtime the unit-test job uses.
110152 - uses: actions/setup-node@v4
@@ -113,6 +155,12 @@ jobs: · 2 unmodified lines
113155
114156 - run: bun install --frozen-lockfile
115157
158+ - name: Cache Playwright browsers
159+ uses: actions/cache@v4
160+ with:
161+ path: ~/.cache/ms-playwright
162+ key: ${{ runner.os }}-playwright-1.60.0
163+
116164 # Install from e2e so bunx resolves ITS pinned playwright (the version
117165 # the tests run against) rather than floating to the latest.
118166 - name: Install Playwright Chromium
@@ -152,6 +200,14 @@ jobs: · 33 unmodified lines
152200 with:
153201 bun-version: 1.3.11
154202
203+ - name: Cache Bun package cache
204+ uses: actions/cache@v4
205+ with:
206+ path: ~/.bun/install/cache
207+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
208+ restore-keys: |
209+ ${{ runner.os }}-bun-1.3.11-
210+
155211 # The local scenarios boot a real `executor web` (which spawns a Node
156212 # sidecar) and some drive a browser, so pin Node 22 and install Chromium.
157213 - uses: actions/setup-node@v4
@@ -160,6 +216,12 @@ jobs: · 2 unmodified lines
160216
161217 - run: bun install --frozen-lockfile
162218
219+ - name: Cache Playwright browsers
220+ uses: actions/cache@v4
221+ with:
222+ path: ~/.cache/ms-playwright
223+ key: ${{ runner.os }}-playwright-1.60.0
224+
163225 # `chromium` and the new `chromium-headless-shell` ship as separate
164226 # downloads; the browser-driven scenarios launch the headless shell.
165227 # Install from e2e so bunx resolves ITS pinned playwright (the version the
@@ -190,6 +252,14 @@ jobs: · 24 unmodified lines
190252 with:
191253 bun-version: 1.3.11
192254
255+ - name: Cache Bun package cache
256+ uses: actions/cache@v4
257+ with:
258+ path: ~/.bun/install/cache
259+ key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
260+ restore-keys: |
261+ ${{ runner.os }}-bun-1.3.11-
262+
193263 - run: bun install --frozen-lockfile
194264
195265 - name: Build web app
@@ -221,3 +291,5 @@ jobs: · 25 unmodified lines
221291 file: apps/host-selfhost/Dockerfile
222292 push: false
223293 tags: executor-selfhost:ci
294+ cache-from: type=gha
295+ cache-to: type=gha,mode=max
apps/cloud/src/db/db.ts+6−10
@@ -73,16 +73,12 @@ const makePostgresResource = (): DbResource => { · 72 unmodified lines
7373 sql,
7474 db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb,
7575 close: () =>
76− Effect.sync(() => {
77− void Effect.runFork(
78− Effect.ignore(
79− Effect.tryPromise({
80− try: () => sql.end({ timeout: 0 }),
81− catch: (cause) => cause,
82− }),
83− ),
84− );
85− }),
76+ Effect.ignore(
77+ Effect.tryPromise({
78+ try: () => sql.end({ timeout: 0 }),
79+ catch: (cause) => cause,
80+ }),
81+ ),
8682 };
8783 };
8884
apps/host-selfhost/src/boot.test.ts+14−8
@@ -2,20 +2,26 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { afterAll, expect, test } from "@effect/vitest";
5+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
66
77 // Config reads the environment, so point it at a throwaway data dir before
88 // importing the app graph.
99 process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-boot-"));
1010
11−const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app");
11+let handler!: (request: Request) => Promise<Response>;
12+let dispose: () => Promise<void> = async () => {};
1213
13−const { handler, dispose } = await makeSelfHostTestApp({
14− identity: singleAdminIdentityLayer({
15− userId: "admin",
16− organizationId: "default-org",
17− organizationName: "Default",
18− }),
14+beforeAll(async () => {
15+ const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app");
16+ const app = await makeSelfHostTestApp({
17+ identity: singleAdminIdentityLayer({
18+ userId: "admin",
19+ organizationId: "default-org",
20+ organizationName: "Default",
21+ }),
22+ });
23+ handler = app.handler;
24+ dispose = app.dispose;
1925 });
2026 afterAll(() => dispose());
2127
apps/host-selfhost/src/multi-user.test.ts+9−3
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { afterAll, expect, test } from "@effect/vitest";
5+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
66 import { connectionIdentifier } from "@executor-js/sdk/shared";
77
88 import { mintInviteCode } from "./testing/mint-invite";
@@ -13,9 +13,15 @@ process.env.BETTER_AUTH_SECRET = "multi-user-secret-0123456789-abcdefghij-klmn"; · 4 unmodified lines
1313 process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@multi.test";
1414 process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
1515
16−const { makeSelfHostApiHandler } = await import("./app");
16+let handler!: (request: Request) => Promise<Response>;
17+let dispose: () => Promise<void> = async () => {};
1718
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+});
1925 afterAll(() => dispose());
2026
2127 const BASE = "http://localhost:4788";
apps/host-selfhost/src/scope-isolation.test.ts+11−5
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { afterAll, expect, test } from "@effect/vitest";
5+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
66 import { connectionIdentifier } from "@executor-js/sdk/shared";
77
88 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
1313 // request-scoped. Each identity is its own (org, user): in v2 the org is the
1414 // tenant (catalog partition) and the user is the acting subject (drives
1515 // `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 () => {};
1718
18−const { handler, dispose } = await makeSelfHostTestApp({
19− identity: headerIdentityLayer,
19+beforeAll(async () => {
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;
2026 });
2127 afterAll(() => dispose());
2228
@@ -135,7 +141,7 @@ test("concurrent requests with distinct identities get disjoint, correct executo · 112 unmodified lines
135141 expect(addresses.some((a) => a.includes(connectionNameForUser(other)))).toBe(false);
136142 }
137143 });
138−}, 30_000);
144+});
139145
140146 test("a request with no identity is rejected", async () => {
141147 const res = await handler(new Request("http://localhost/api/connections"));
apps/host-selfhost/src/secrets-integration.test.ts+14−7
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; · 3 unmodified lines
44 import { join } from "node:path";
55
66 import { Effect, Layer } from "effect";
7−import { afterAll, expect, test } from "@effect/vitest";
7+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
88
99 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
1010 import { makeScopedExecutor } from "@executor-js/api/server";
@@ -32,13 +32,20 @@ const createScopedExecutor = ( · 21 unmodified lines
3232 Effect.provide(SelfHostScopedExecutorSeams),
3333 );
3434
35−const dbHandle = await createSelfHostDb({
36− path: dbPath,
37− namespace: "executor_selfhost",
38− version: "1.0.0",
35+let dbLayer!: Layer.Layer<SelfHostDb>;
36+let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
37+
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();
3948 });
40−const dbLayer = Layer.succeed(SelfHostDb)(dbHandle);
41−afterAll(() => dbHandle.close());
4249
4350 const TINY_SPEC = JSON.stringify({
4451 openapi: "3.0.0",
apps/host-selfhost/src/sources-mcp.test.ts+10−3
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines
33 import { join } from "node:path";
44
55 import { Effect, Layer } from "effect";
6−import { afterAll, expect, test } from "@effect/vitest";
6+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
77
88 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
99 import { makeScopedExecutor } from "@executor-js/api/server";
@@ -50,8 +50,15 @@ const TINY_SPEC = JSON.stringify({ · 40 unmodified lines
5050 },
5151 });
5252
53−const { makeSelfHostApiHandler } = await import("./app");
54−const { handler, dispose } = await makeSelfHostApiHandler({ dbPath });
53+let handler!: (request: Request) => Promise<Response>;
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+});
5562 afterAll(() => dispose());
5663
5764 const BASE = "http://localhost:4788";
apps/host-selfhost/src/sources.test.ts+14−7
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines
33 import { join } from "node:path";
44
55 import { Effect, Layer } from "effect";
6−import { afterAll, expect, test } from "@effect/vitest";
6+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
77
88 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
99 import { makeScopedExecutor } from "@executor-js/api/server";
@@ -28,13 +28,20 @@ const createScopedExecutor = ( · 18 unmodified lines
2828 const dataDir = mkdtempSync(join(tmpdir(), "eh-src-"));
2929 process.env.EXECUTOR_DATA_DIR = dataDir;
3030
31−const dbHandle = await createSelfHostDb({
32− path: join(dataDir, "data.db"),
33− namespace: "executor_selfhost",
34− version: "1.0.0",
31+let dbLayer!: Layer.Layer<SelfHostDb>;
32+let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
33+
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();
3544 });
36−const dbLayer = Layer.succeed(SelfHostDb)(dbHandle);
37−afterAll(() => dbHandle.close());
3845
3946 // Inline OpenAPI spec so the test doesn't depend on the network to register.
4047 const TINY_SPEC = JSON.stringify({
apps/host-selfhost/vitest.config.ts+13−25
@@ -4,30 +4,18 @@ export default defineConfig({ · 3 unmodified lines
44 test: {
55 include: ["src/**/*.test.ts"],
66 passWithNoTests: true,
7− // These are integration suites: most boot a full self-host app graph
8− // (Better Auth + libSQL + MCP + plugins) at module load, then drive it
9− // over the in-memory handler, and some (scope-isolation) fire dozens of
10− // concurrent requests through it. Each boot is CPU-heavy and every query
11− // serializes through the one shared libSQL connection, so when many files
12− // boot at once they oversubscribe the CPU and starve the event loop: an
13− // in-flight request stalls indefinitely and the test times out. Which
14− // files lose the race is nondeterministic, which is the CI flakiness.
15− //
16− // Vitest sizes its fork pool to the core count, so a many-core machine
17− // oversubscribes HARDER than a small CI runner. Bound the concurrency to
18− // an absolute number instead, so behavior is the same everywhere: at most
19− // two heavy boots run together, which fits a 4-vCPU runner without
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− },
7+ // These are integration suites: several files boot a full self-host app
8+ // graph (Better Auth + libSQL + MCP + plugins) in beforeAll, then drive it
9+ // over the in-memory handler. Each boot is CPU-heavy and every query
10+ // serializes through the one shared libSQL connection, so parallel files can
11+ // oversubscribe CI and starve in-flight requests. Run files serially and
12+ // give lifecycle hooks room for the cold graph boot. Tests that fan out
13+ // many concurrent requests (scope-isolation) stretch well past 30s when
14+ // sibling turbo tasks contend for the same cores, so the budget is sized
15+ // for a loaded runner, not an idle one.
16+ fileParallelism: false,
17+ maxWorkers: 1,
18+ testTimeout: 120_000,
19+ hookTimeout: 60_000,
3220 },
3321 });
e2e/cloud/auth-routing-flow.test.ts+11−1
@@ -56,7 +56,17 @@ scenario( · 55 unmodified lines
5656 });
5757
5858 await step("Create the first org → canonical dashboard at /<slug>", async () => {
59− await page.getByPlaceholder("Northwind Labs").fill("Flow Test Org");
59+ const orgName = "Flow Test Org";
60+ const orgNameInput = page.getByPlaceholder("Northwind Labs");
61+ await page.waitForLoadState("networkidle");
62+ await orgNameInput.fill(orgName);
63+ await page.waitForTimeout(250);
64+ if ((await orgNameInput.inputValue()) !== orgName) {
65+ await orgNameInput.fill(orgName);
66+ }
67+ expect(await orgNameInput.inputValue(), "org name survives create-org hydration").toBe(
68+ orgName,
69+ );
6070 await page.getByRole("button", { name: "Create organization" }).click();
6171 await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
6272 await page.getByRole("button", { name: "Continue to app" }).click();
e2e/local/local-server.ts+4−4
@@ -76,15 +76,15 @@ export const withLocalServer = ( · 75 unmodified lines
7676 return TOKEN_URL.test(current.text);
7777 },
7878 // A cold `executor web` runs vite's optimizeDeps before it prints
79− // the URL; 180s is generous headroom over the warm ~3s boot. Kept
80− // BELOW each scenario's test timeout (240s) so that on a slow or
79+ // the URL; 240s leaves headroom for isolated CI cold boots. Kept
80+ // BELOW each scenario's test timeout (300s) so that on a slow or
8181 // wedged boot THIS error surfaces, with the terminal tail, rather
8282 // than racing vitest's generic timeout at the same deadline.
83− { timeoutMs: 180_000 },
83+ { timeoutMs: 240_000 },
8484 )
8585 .catch((cause: unknown) => {
8686 throw new Error(
87− `executor web --foreground printed no ?_token URL within 180s (${String(cause)}):\n${lastText.slice(-600)}`,
87+ `executor web --foreground printed no ?_token URL within 240s (${String(cause)}):\n${lastText.slice(-600)}`,
8888 );
8989 });
9090 const url = TOKEN_URL.exec(snapshot.text)?.[0];
e2e/local/stdio-mcp.test.ts+2−2
@@ -40,13 +40,13 @@ const SECRET = "s3cr3t-from-the-vault"; · 39 unmodified lines
4040 scenario(
4141 "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret",
4242 // Must stay STRICTLY greater than the boot-URL wait in `withLocalServer`
43− // (currently 180s). When this CI job runs `stdio-mcp.test.ts` alone it always
43+ // (currently 240s). When this CI job runs `stdio-mcp.test.ts` alone it always
4444 // pays a cold `vite optimizeDeps` boot (no prior file to warm the cache), the
4545 // one variable step. If this test timeout equals the boot wait, both deadlines
4646 // fire together and vitest's generic "Test timed out" wins, swallowing the
4747 // harness's "printed no ?_token URL\n<terminal tail>" error that tells us what
4848 // boot actually got stuck on. Keep the gap so the boot diagnostic surfaces.
49− { timeout: 240_000 },
49+ { timeout: 300_000 },
5050 Effect.gen(function* () {
5151 const cli = yield* Cli;
5252 const runDir = yield* RunDir;
e2e/scenarios/auth-methods-ui.test.ts+2−2
@@ -55,8 +55,8 @@ scenario( · 54 unmodified lines
5555 await headerName.waitFor();
5656 });
5757
58− await step("Add the source with both methods", async () => {
59− await page.getByRole("button", { name: "Add source" }).click();
58+ await step("Add the integration with both methods", async () => {
59+ await page.getByRole("button", { name: "Add integration" }).click();
6060 // onComplete routes to the new integration's detail hub.
6161 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
6262 await page.getByText("Connections").first().waitFor();
e2e/scenarios/google-photos-preset-ui.test.ts+1−1
@@ -26,7 +26,7 @@ scenario( · 25 unmodified lines
2626 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
2727 await dialog.getByRole("link", { name: /^Google Photos\b/ }).click();
2828 await page.waitForURL(/\/integrations\/add\/google/);
29− await page.getByRole("heading", { name: "Add Google" }).waitFor();
29+ await page.getByRole("heading", { name: "Add Google integration" }).waitFor();
3030 });
3131
3232 await step("The Photos preset defaults to the focused namespace and products", async () => {
e2e/scenarios/mcp-catalog-sync-ui.test.ts+2−2
@@ -44,8 +44,8 @@ scenario( · 43 unmodified lines
4444 await page.getByText("Method 1 · Detected").waitFor();
4545 });
4646
47− await step("Add the source", async () => {
48− await page.getByRole("button", { name: "Add source" }).click();
47+ await step("Add the integration", async () => {
48+ await page.getByRole("button", { name: "Add integration" }).click();
4949 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
5050 await page.getByText("Connections").first().waitFor();
5151 });
e2e/scenarios/provider-plugins-ui.test.ts+2−2
@@ -45,7 +45,7 @@ scenario( · 44 unmodified lines
4545 await page.goto("/integrations/add/google", {
4646 waitUntil: "domcontentloaded",
4747 });
48− await page.getByRole("heading", { name: "Add Google" }).waitFor();
48+ await page.getByRole("heading", { name: "Add Google integration" }).waitFor();
4949 await page.getByText("Customize your Google connection").waitFor();
5050 await page.getByText("Gmail").first().waitFor();
5151 await page.getByText("Google Calendar").first().waitFor();
@@ -55,7 +55,7 @@ scenario( · 3 unmodified lines
5555 await page.goto("/integrations/add/microsoft", {
5656 waitUntil: "domcontentloaded",
5757 });
58− await page.getByRole("heading", { name: "Add Microsoft Graph" }).waitFor();
58+ await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor();
5959 await page.getByText("Customize Microsoft Graph").waitFor();
6060 expect(await page.getByText("All Microsoft Graph", { exact: true }).count()).toBe(0);
6161 await page.getByText("Productivity").waitFor();
e2e/selfhost/auth-methods-ui.test.ts+2−2
@@ -64,8 +64,8 @@ scenario( · 63 unmodified lines
6464 await page.getByPlaceholder("Authorization").last().waitFor();
6565 });
6666
67− await step("Add the source with both methods", async () => {
68− await page.getByRole("button", { name: "Add source" }).click();
67+ await step("Add the integration with both methods", async () => {
68+ await page.getByRole("button", { name: "Add integration" }).click();
6969 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, {
7070 timeout: 30_000,
7171 });
e2e/selfhost/mcp-auth-required-add.test.ts+2−2
@@ -67,9 +67,9 @@ scenario( · 66 unmodified lines
6767 await page.getByText("Auth required").first().waitFor();
6868 });
6969
70− await step("Add the source with the declared method", async () => {
70+ await step("Add the integration with the declared method", async () => {
7171 await page.getByPlaceholder("e.g. Linear").fill(name);
72− await page.getByRole("button", { name: "Add source" }).click();
72+ await page.getByRole("button", { name: "Add integration" }).click();
7373 // onComplete routes to the new integration's detail hub.
7474 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
7575 const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
e2e/selfhost/oauth-callback-unauthenticated.test.ts+1−1
@@ -113,7 +113,7 @@ scenario( · 112 unmodified lines
113113 await step("Provider sends a signed-out browser to the OAuth callback", async () => {
114114 const response = await page.goto(callbackPath, { waitUntil: "networkidle" });
115115 expect(response?.status(), "the callback redirects into the login flow").toBe(200);
116− await page.getByText("Sign in to your instance").waitFor();
116+ await page.getByRole("heading", { name: "Sign in" }).waitFor();
117117 });
118118
119119 const loginUrl = new URL(page.url());
e2e/selfhost/posthog-mcp-oauth.test.ts+2−2
@@ -42,9 +42,9 @@ scenario( · 41 unmodified lines
4242 await page.getByText("OAuth metadata is discovered from this server").waitFor();
4343 });
4444
45− await step("Add the PostHog MCP source", async () => {
45+ await step("Add the PostHog MCP integration", async () => {
4646 await page.getByPlaceholder("e.g. Linear").fill(displayName);
47− await page.getByRole("button", { name: "Add source" }).click();
47+ await page.getByRole("button", { name: "Add integration" }).click();
4848 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
4949 const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
5050 expect(landedSlug, "the add flow lands on the created integration").toBe(String(slug));
e2e/vitest.config.ts+1−1
@@ -110,7 +110,7 @@ export default defineConfig({ · 109 unmodified lines
110110 include: ["local/**/*.test.ts"],
111111 globalSetup: [],
112112 fileParallelism: false,
113− testTimeout: 240_000,
113+ testTimeout: 300_000,
114114 }),
115115 // The supervised CLI daemon inside a guest VM, one project per OS. The
116116 // globalsetup provisions a VM, `executor service install`s the daemon, and
package.json+1−1
@@ -35,7 +35,7 @@ · 34 unmodified lines
3535 "dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'",
3636 "dev:desktop": "turbo run dev",
3737 "dev:cli": "EXECUTOR_DEV=1 EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-apps/local/.executor-dev} bun run apps/cli/src/main.ts",
38− "test": "turbo run test --filter=!@executor-js/e2e",
38+ "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}",
3939 "test:e2e": "bun run --cwd e2e test",
4040 "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
4141 "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
packages/core/sdk/src/testing.ts+12−1
@@ -1,5 +1,6 @@
1+import { createConnection } from "node:net";
12 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2−import { Context, Data, Effect, Layer, Predicate, Scope as EffectScope } from "effect";
3+import { Context, Data, Effect, Layer, Predicate, Schedule, Scope as EffectScope } from "effect";
34 import {
45 HttpClient,
56 HttpRouter,
@@ -109,6 +110,16 @@ const makeTestHttpServer = ( · 103 unmodified lines
109110 return yield* new TestHttpServerAddressError({ address });
110111 }
111112 const client = Context.get(context, HttpClient.HttpClient);
113+ // Under a loaded machine the listen callback can fire before the socket
114+ // reliably accepts; probe at the TCP level (no HTTP request, so handlers
115+ // that record requests never see it) until a connect succeeds.
116+ yield* Effect.callback<void, TestHttpServerServeError>((resume) => {
117+ const socket = createConnection({ host: "127.0.0.1", port: address.port }, () => {
118+ socket.end();
119+ resume(Effect.void);
120+ });
121+ socket.on("error", (cause) => resume(Effect.fail(new TestHttpServerServeError({ cause }))));
122+ }).pipe(Effect.retry(Schedule.both(Schedule.spaced("10 millis"), Schedule.recurs(100))));
112123 const baseUrl = `http://127.0.0.1:${address.port}`;
113124 return {
114125 baseUrl,
packages/plugins/graphql/src/sdk/plugin.test.ts+6−5
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 makeGreetingGraphqlSchema,
3333 serveGraphqlFailureTestServer,
3434 serveGraphqlTestServer,
35+ waitForRecordedRequests,
3536 } from "../testing";
3637
3738 // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/
@@ -313,9 +314,8 @@ describe("graphqlPlugin real protocol server", () => { · 275 unmodified lines
313314 value: "unused",
314315 });
315316
316− const afterConnect = yield* server.requests;
317− expect(afterConnect.some((request) => request.payload.query?.includes("__schema"))).toBe(
318− true,
317+ yield* waitForRecordedRequests(server.requests, (requests) =>
318+ requests.some((request) => request.payload.query?.includes("__schema")),
319319 );
320320
321321 const tools = yield* executor.tools.list();
@@ -550,11 +550,12 @@ describe("graphqlPlugin real protocol server", () => { · 228 unmodified lines
550550 value: "connect-token",
551551 });
552552
553− const afterConnect = yield* server.requests;
553+ const afterConnect = yield* waitForRecordedRequests(server.requests, (requests) =>
554+ requests.some((request) => request.payload.query?.includes("__schema")),
555+ );
554556 const introspectionRequests = afterConnect.filter((request) =>
555557 request.payload.query?.includes("__schema"),
556558 );
557− expect(introspectionRequests.length).toBeGreaterThan(0);
558559 expect(introspectionRequests[0]?.headers.authorization).toBe("Bearer connect-token");
559560
560561 // The introspected operations become per-connection tools.
packages/plugins/graphql/src/testing/index.ts+23−0
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 Layer,
66 Predicate,
77 Ref,
8+ Schedule,
89 Schema as EffectSchema,
910 Scope,
1011 } from "effect";
@@ -54,6 +55,28 @@ export interface GraphqlTestServerShape { · 43 unmodified lines
5455 readonly clearRequests: Effect.Effect<void>;
5556 }
5657
58+export class GraphqlTestRecorderTimeoutError extends Data.TaggedError(
59+ "GraphqlTestRecorderTimeoutError",
60+)<{
61+ readonly message: string;
62+}> {}
63+
64+/** Poll the request recorder until `predicate` matches. Yoga captures requests
65+ * asynchronously (`captureRequest` runs via `Effect.runPromise` in the handler
66+ * context), so connect/invoke can resolve before the ref updates under load. */
67+export const waitForRecordedRequests = (
68+ requests: Effect.Effect<readonly GraphqlTestRequest[]>,
69+ predicate: (requests: readonly GraphqlTestRequest[]) => boolean,
70+ message = "timed out waiting for matching recorded GraphQL requests",
71+): Effect.Effect<readonly GraphqlTestRequest[], GraphqlTestRecorderTimeoutError> =>
72+ requests.pipe(
73+ Effect.filterOrFail(
74+ (all) => predicate(all),
75+ () => new GraphqlTestRecorderTimeoutError({ message }),
76+ ),
77+ Effect.retry(Schedule.both(Schedule.spaced("50 millis"), Schedule.recurs(100))),
78+ );
79+
5780 class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
5881 readonly address: unknown;
5982 }> {}
packages/react/src/lib/async-result.test.ts+8−2
@@ -5,14 +5,20 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines
55 import { isAsyncResultLoading } from "./async-result";
66
77 describe("isAsyncResultLoading", () => {
8− it("treats initial and waiting async results as loading", () => {
8+ it("treats initial and waiting-without-value async results as loading", () => {
99 expect(isAsyncResultLoading(AsyncResult.initial())).toBe(true);
1010 expect(isAsyncResultLoading(AsyncResult.initial(true))).toBe(true);
11− expect(isAsyncResultLoading(AsyncResult.success(["cached"], { waiting: true }))).toBe(true);
11+ expect(isAsyncResultLoading(AsyncResult.failure(Cause.fail("boom"), { waiting: true }))).toBe(
12+ true,
13+ );
1214 });
1315
1416 it("does not treat settled success or failure as loading", () => {
1517 expect(isAsyncResultLoading(AsyncResult.success(["ready"]))).toBe(false);
1618 expect(isAsyncResultLoading(AsyncResult.failure(Cause.fail("boom")))).toBe(false);
1719 });
20+
21+ it("keeps stale values rendered while a refresh is waiting", () => {
22+ expect(isAsyncResultLoading(AsyncResult.success(["cached"], { waiting: true }))).toBe(false);
23+ });
1824 });
packages/react/src/lib/async-result.ts+5−1
@@ -1,5 +1,9 @@
11 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
2+import * as Option from "effect/Option";
23
34 export function isAsyncResultLoading<A, E>(result: AsyncResult.AsyncResult<A, E>): boolean {
4− return AsyncResult.isInitial(result) || AsyncResult.isWaiting(result);
5+ return (
6+ AsyncResult.isInitial(result) ||
7+ (AsyncResult.isWaiting(result) && Option.isNone(AsyncResult.value(result)))
8+ );
59 }