Skip to content

Commit 8e4ef7b

Browse files
authored
Fix the flaky Composio boot test: record only the vendor's own request (#1035)
The "key it was given is the key the vendor sends" test replaced the process-wide fetch and process.on for the second and a half the raw client spends retrying a refused connection. bun test runs every file in one process, and the browser extension's tests leave timers running (its activity heartbeat, connection check and streamer reconnect) that keep calling fetch long after their files finish. When one of them fell inside that window it was recorded as a keyless attempt to 127.0.0.1, and the test failed with "Received: null" while Composio's client was behaving. The recorders now apply only inside an AsyncLocalStorage scope around the test body; calls from anywhere else pass through to the real globals. The test also waits for the refusal with a plain await instead of expect().rejects, because Bun waits on that by running the event loop inside the call, which runs other files' timers nested in the scope.
1 parent 56785b7 commit 8e4ef7b

1 file changed

Lines changed: 53 additions & 10 deletions

File tree

‎server/tests/composio-adapter.test.ts‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import { AsyncLocalStorage } from "node:async_hooks";
23
import { Composio, telemetry } from "@composio/core";
34
import {
45
BrokerAccountGoneError,
@@ -6727,6 +6728,19 @@ describe("the key becoming a vendor, and which delete that vendor carries", () =
67276728
* is opened either way, and the assertions are lists of what the vendor TRIED — which is the fact
67286729
* worth pinning, since a flag flipped back would be a try that succeeded in production.
67296730
*
6731+
* AND THE RECORDERS RECORD ONLY THE VENDOR UNDER TEST, because both globals belong to the whole
6732+
* process and `bun test` runs every test file in that one process. Work an EARLIER file left
6733+
* running reaches the same `fetch`: the browser extension's tests leave its activity heartbeat,
6734+
* its connection check and its streamer's reconnect timers behind, and those keep posting to
6735+
* `http://127.0.0.1:…/api/recorder/…` minutes after their own files have finished. One of them
6736+
* landing inside the second-and-a-half the raw client spends retrying below was recorded as an
6737+
* attempt with no key, to a host that is not Composio's, and failed this suite on CI while the
6738+
* vendor itself was behaving. So the body runs inside an `AsyncLocalStorage` scope, which every
6739+
* microtask, timer and promise it starts inherits, and anything calling either global from
6740+
* outside that scope is passed straight through to the real one, untouched and unrecorded, as if
6741+
* this test were not running. The scope holds only while `body` waits the ordinary way, which is
6742+
* why the helper says how it must wait.
6743+
*
67306744
* THE HANDLER HALF WOULD HAVE BEEN A VACUOUS ASSERTION AND IS NOT, which is worth knowing before
67316745
* anybody simplifies it. `registerExitHandlers` guards on an `exitHandlersRegistered` flag held by
67326746
* a MODULE-LEVEL singleton (`src/telemetry/Telemetry.ts:46,315-321`), so it fires at most once per
@@ -6741,7 +6755,15 @@ describe("what constructing the vendor is allowed to do on boot", () => {
67416755
type Attempt = { url: string; apiKey: string | null };
67426756

67436757
/**
6744-
* Runs `body` with the network and the process's own listener registry replaced by recorders.
6758+
* Runs `body` with the network and the process's own listener registry replaced by recorders,
6759+
* for `body` and whatever it sets going, and for nothing else in the process.
6760+
*
6761+
* `body` MUST WAIT WITH A PLAIN `await`, NEVER WITH `expect(...).rejects` OR `.resolves`. Bun
6762+
* waits for the promise those are given by running the event loop inside the call, so every
6763+
* other file's timer that falls due meanwhile runs nested in `body`'s scope, inherits it, and is
6764+
* recorded as the vendor's own. That was observed with Bun 1.4.3: with the scope below in place
6765+
* and `expect(broker.listApps()).rejects` still in the test, the extension streamer's reconnect
6766+
* was recorded with no key, its stack running through `run` and this file's `body`.
67456767
*
67466768
* `NODE_ENV` is forced away from `test` for the duration because the SDK's own
67476769
* `shouldSendTelemetry()` short-circuits on `test` and `ci`
@@ -6760,7 +6782,18 @@ describe("what constructing the vendor is allowed to do on boot", () => {
67606782
const memo = telemetry as unknown as { exitHandlersRegistered: boolean };
67616783
const realMemo = memo.exitHandlersRegistered;
67626784

6785+
/*
6786+
* WHOSE CALL IT IS, WHICH IS WHAT MAKES THE RECORDING THIS TEST'S. A fresh scope per run, so
6787+
* `getStore()` is true only in `body`'s own async descendants: the client it constructs, the
6788+
* request it builds, the raw client's retry timers, and the SDK's deferred telemetry. A caller
6789+
* outside it is some other file's leftover work (see the describe's header), and it gets the
6790+
* real global back as though nothing here were installed.
6791+
*/
6792+
const vendorUnderTest = new AsyncLocalStorage<true>();
6793+
67636794
globalThis.fetch = ((input: unknown, init?: RequestInit) => {
6795+
if (!vendorUnderTest.getStore())
6796+
return realFetch(input as Parameters<typeof fetch>[0], init);
67646797
const request = input as { url?: string; headers?: HeadersInit };
67656798
const headers = new Headers(init?.headers ?? request?.headers ?? {});
67666799
attempts.push({
@@ -6772,8 +6805,10 @@ describe("what constructing the vendor is allowed to do on boot", () => {
67726805
return Promise.reject(new Error("no network in this test"));
67736806
}) as typeof globalThis.fetch;
67746807

6775-
process.on = ((event: string) => {
6776-
events.push(event);
6808+
process.on = ((...registration: Parameters<typeof process.on>) => {
6809+
if (!vendorUnderTest.getStore())
6810+
return realOn.apply(process, registration);
6811+
events.push(String(registration[0]));
67776812
return process;
67786813
}) as typeof process.on;
67796814

@@ -6792,11 +6827,13 @@ describe("what constructing the vendor is allowed to do on boot", () => {
67926827
memo.exitHandlersRegistered = false;
67936828

67946829
try {
6795-
await body();
6796-
// One turn of the macrotask queue, because the SDK defers its initialisation metric through
6797-
// `queueMicrotask` (`src/telemetry/Telemetry.ts:241-257`) — a `setTimeout` lands after every
6798-
// microtask that defer could have queued, so the recorder sees the POST before it is undone.
6799-
await new Promise((resolve) => setTimeout(resolve, 0));
6830+
await vendorUnderTest.run(true, async () => {
6831+
await body();
6832+
// One turn of the macrotask queue, because the SDK defers its initialisation metric through
6833+
// `queueMicrotask` (`src/telemetry/Telemetry.ts:241-257`) — a `setTimeout` lands after every
6834+
// microtask that defer could have queued, so the recorder sees the POST before it is undone.
6835+
await new Promise((resolve) => setTimeout(resolve, 0));
6836+
});
68006837
} finally {
68016838
globalThis.fetch = realFetch;
68026839
process.on = realOn;
@@ -6837,12 +6874,18 @@ describe("what constructing the vendor is allowed to do on boot", () => {
68376874
});
68386875

68396876
test("the key it was given is the key the vendor sends, to Composio's own host", async () => {
6877+
let refusal: unknown;
68406878
const { attempts } = await withRecordedTransport(async () => {
68416879
const { broker } = createComposioClient("never-dialled");
68426880
// It rejects because the transport above rejects; what is under test is the request that was
6843-
// built before it did, not the refusal that comes back.
6844-
await expect(broker.listApps()).rejects.toThrow();
6881+
// built before it did, not the refusal that comes back. Caught with a plain `await` and not
6882+
// `expect(...).rejects` for the reason given on `withRecordedTransport`.
6883+
refusal = await broker.listApps().then(
6884+
() => undefined,
6885+
(error: unknown) => error,
6886+
);
68456887
});
6888+
expect(refusal).toBeInstanceOf(Error);
68466889

68476890
/*
68486891
* EVERY attempt, not the first one, because the claim worth making is about all of them. The

0 commit comments

Comments
 (0)