* 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 packages (5 of 27 files). Show all
Unified · Split
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createConnection } from "node:net"; | ||
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 2 | 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"; |
| 3 | import { | 4 | import { |
| 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, |
| @@ -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. |
| @@ -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 | ||
| 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 | |||
| 57 | class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{ | 80 | class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{ |
| 58 | readonly address: unknown; | 81 | readonly address: unknown; |
| 59 | }> {} | 82 | }> {} |
| @@ -5,14 +5,20 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines | |||
| 5 | import { isAsyncResultLoading } from "./async-result"; | 5 | import { isAsyncResultLoading } from "./async-result"; |
| 6 | 6 | ||
| 7 | describe("isAsyncResultLoading", () => { | 7 | describe("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 | }); |
| @@ -1,5 +1,9 @@ | |||
| 1 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 1 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 2 | import * as Option from "effect/Option"; | ||
| 2 | 3 | ||
| 3 | export function isAsyncResultLoading<A, E>(result: AsyncResult.AsyncResult<A, E>): boolean { | 4 | 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 | ); | ||
| 5 | } | 9 | } |