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

Showing the changes to packages (5 of 27 files). Show all

Unified · Split

5 files
  1. packages/core/sdk/src/testing.ts +12 −1
  2. packages/plugins/graphql/src/sdk/plugin.test.ts +6 −5
  3. packages/plugins/graphql/src/testing/index.ts +23 −0
  4. packages/react/src/lib/async-result.test.ts +8 −2
  5. packages/react/src/lib/async-result.ts +5 −1
packages/core/sdk/src/testing.ts+12−1
@@ -1,5 +1,6 @@
1import { createConnection } from "node:net";
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";2import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import { Context, Data, Effect, Layer, Predicate, Scope as EffectScope } from "effect";3import { Context, Data, Effect, Layer, Predicate, Schedule, Scope as EffectScope } from "effect";
3import {4import {
4 HttpClient,5 HttpClient,
5 HttpRouter,6 HttpRouter,
@@ -109,6 +110,16 @@ const makeTestHttpServer = ( · 103 unmodified lines
109 return yield* new TestHttpServerAddressError({ address });110 return yield* new TestHttpServerAddressError({ address });
110 }111 }
111 const client = Context.get(context, HttpClient.HttpClient);112 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))));
112 const baseUrl = `http://127.0.0.1:${address.port}`;123 const baseUrl = `http://127.0.0.1:${address.port}`;
113 return {124 return {
114 baseUrl,125 baseUrl,
packages/plugins/graphql/src/sdk/plugin.test.ts+6−5
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
32 makeGreetingGraphqlSchema,32 makeGreetingGraphqlSchema,
33 serveGraphqlFailureTestServer,33 serveGraphqlFailureTestServer,
34 serveGraphqlTestServer,34 serveGraphqlTestServer,
35 waitForRecordedRequests,
35} from "../testing";36} from "../testing";
36 37
37// removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/38// removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/
@@ -313,9 +314,8 @@ describe("graphqlPlugin real protocol server", () => { · 275 unmodified lines
313 value: "unused",314 value: "unused",
314 });315 });
315 316
316 const afterConnect = yield* server.requests;317 yield* waitForRecordedRequests(server.requests, (requests) =>
317 expect(afterConnect.some((request) => request.payload.query?.includes("__schema"))).toBe(318 requests.some((request) => request.payload.query?.includes("__schema")),
318 true,
319 );319 );
320 320
321 const tools = yield* executor.tools.list();321 const tools = yield* executor.tools.list();
@@ -550,11 +550,12 @@ describe("graphqlPlugin real protocol server", () => { · 228 unmodified lines
550 value: "connect-token",550 value: "connect-token",
551 });551 });
552 552
553 const afterConnect = yield* server.requests;553 const afterConnect = yield* waitForRecordedRequests(server.requests, (requests) =>
554 requests.some((request) => request.payload.query?.includes("__schema")),
555 );
554 const introspectionRequests = afterConnect.filter((request) =>556 const introspectionRequests = afterConnect.filter((request) =>
555 request.payload.query?.includes("__schema"),557 request.payload.query?.includes("__schema"),
556 );558 );
557 expect(introspectionRequests.length).toBeGreaterThan(0);
558 expect(introspectionRequests[0]?.headers.authorization).toBe("Bearer connect-token");559 expect(introspectionRequests[0]?.headers.authorization).toBe("Bearer connect-token");
559 560
560 // The introspected operations become per-connection tools.561 // The introspected operations become per-connection tools.
packages/plugins/graphql/src/testing/index.ts+23−0
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 Layer,5 Layer,
6 Predicate,6 Predicate,
7 Ref,7 Ref,
8 Schedule,
8 Schema as EffectSchema,9 Schema as EffectSchema,
9 Scope,10 Scope,
10} from "effect";11} from "effect";
@@ -54,6 +55,28 @@ export interface GraphqlTestServerShape { · 43 unmodified lines
54 readonly clearRequests: Effect.Effect<void>;55 readonly clearRequests: Effect.Effect<void>;
55}56}
56 57
58export 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. */
67export 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
57class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{80class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
58 readonly address: unknown;81 readonly address: unknown;
59}> {}82}> {}
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
5import { isAsyncResultLoading } from "./async-result";5import { isAsyncResultLoading } from "./async-result";
6 6
7describe("isAsyncResultLoading", () => {7describe("isAsyncResultLoading", () => {
8 it("treats initial and waiting async results as loading", () => {8 it("treats initial and waiting-without-value async results as loading", () => {
9 expect(isAsyncResultLoading(AsyncResult.initial())).toBe(true);9 expect(isAsyncResultLoading(AsyncResult.initial())).toBe(true);
10 expect(isAsyncResultLoading(AsyncResult.initial(true))).toBe(true);10 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 );
12 });14 });
13 15
14 it("does not treat settled success or failure as loading", () => {16 it("does not treat settled success or failure as loading", () => {
15 expect(isAsyncResultLoading(AsyncResult.success(["ready"]))).toBe(false);17 expect(isAsyncResultLoading(AsyncResult.success(["ready"]))).toBe(false);
16 expect(isAsyncResultLoading(AsyncResult.failure(Cause.fail("boom")))).toBe(false);18 expect(isAsyncResultLoading(AsyncResult.failure(Cause.fail("boom")))).toBe(false);
17 });19 });
20
21 it("keeps stale values rendered while a refresh is waiting", () => {
22 expect(isAsyncResultLoading(AsyncResult.success(["cached"], { waiting: true }))).toBe(false);
23 });
18});24});
packages/react/src/lib/async-result.ts+5−1
@@ -1,5 +1,9 @@
1import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";1import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
2import * as Option from "effect/Option";
2 3
3export function isAsyncResultLoading<A, E>(result: AsyncResult.AsyncResult<A, E>): boolean {4export 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 );
5}9}