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 @@
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 }