Skip to content

Commit 34bedce

Browse files
authored
test(browser): stop the unit suite losing its page mid-run (#1596)
1 parent 0853c39 commit 34bedce

5 files changed

Lines changed: 75 additions & 22 deletions

File tree

config/vitest-utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import { dirname, resolve } from "path";
22

3+
/**
4+
* Test-isolation backstops shared by every vitest config, so protection does
5+
* not depend on which runner a test file happens to execute under —
6+
* `test/unit` runs under both the node and the browser config.
7+
*
8+
* Vitest restores spies and unstubs globals between tests, so a test that
9+
* forgets to undo either one cannot leak into the tests that follow. This
10+
* matters most under the browser config, which shares a single iframe — and
11+
* therefore a single module registry — across every test file.
12+
*
13+
* Scope is spies and `vi.stubGlobal` only. Module-level state — a `vi.mock`,
14+
* or a mutation of an SDK singleton like `Config` or `SERVER_TIME_MAP` — is
15+
* not restored, and under a shared registry it outlives the file that set it.
16+
*/
17+
export const mockSafeguards = {
18+
restoreMocks: true,
19+
unstubGlobals: true,
20+
} as const;
21+
322
export function aliasHttpClientToAxiosSource(isAxios: boolean) {
423
const fetchClientSource = resolve(__dirname, "../src/http-client/index.js");
524
const fetchClientDirectory = resolve(__dirname, "../src/http-client");

config/vitest.config.browser.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineConfig } from "vitest/config";
22
import { playwright } from "@vitest/browser-playwright";
33
import packageJson from "../package.json" with { type: "json" };
4-
import { aliasHttpClientToAxiosSource } from "./vitest-utils";
4+
import { aliasHttpClientToAxiosSource, mockSafeguards } from "./vitest-utils";
55
import { resolve } from "path";
66
const isAxios = process.env.TRANSPORT === "axios";
77

@@ -20,6 +20,30 @@ export default defineConfig({
2020
test: {
2121
globals: true,
2222
environment: "jsdom",
23+
// Reuse one iframe across all test files instead of creating a fresh one
24+
// per file. Every test file imports the SDK source entrypoint, so with
25+
// isolation each of the 117 files re-evaluated the ~470-module `src/xdr`
26+
// graph from scratch; the browser page grew until Firefox lost it mid-run
27+
// ("Browser connection was closed while running tests"), consistently
28+
// around file ~60 with every test that had run passing. Sharing the iframe
29+
// evaluates that graph once, which keeps memory flat and cuts the run from
30+
// ~16s to ~6s.
31+
//
32+
// The trade-off is that the module registry is shared across files, so
33+
// module-level state now outlives the file that created it. A
34+
// module-level `vi.mock` leaks into every file that runs after it — avoid
35+
// module mocks for that reason: tests that need a stubbed transport
36+
// inject a `Server` and spy on `server.httpClient` instead (see
37+
// test/unit/contract/client_from.test.ts and test/unit/server/soroban/).
38+
// Per-test spies are fine — they are attached to an instance built inside
39+
// the test, so they cannot outlive it. The SDK's own module-level state
40+
// (`Config`, `SERVER_TIME_MAP`) is shared the same way, and the safeguards
41+
// below do not restore it: a test that mutates it must reset it itself.
42+
isolate: false,
43+
// Backstop for the mock rule above — spies and `vi.stubGlobal` only, not
44+
// module state. Shared with every other config so the guarantee does not
45+
// depend on which runner executes the file.
46+
...mockSafeguards,
2347
coverage: {
2448
provider: "istanbul",
2549
reporter: ["text", "html", "lcov"],

config/vitest.config.e2e.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { defineConfig } from 'vitest/config'
22
import { resolve } from 'path'
3+
import { mockSafeguards } from './vitest-utils'
34

45
export default defineConfig({
56
test: {
67
globals: true,
78
environment: 'node',
9+
// Shared with the other configs so the cleanup guarantee is uniform; inert
10+
// here today, since test/e2e uses no spies or global stubs.
11+
...mockSafeguards,
812
coverage: {
913
provider: 'v8',
1014
reporter: ['text', 'html', 'lcov'],

config/vitest.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineConfig } from "vitest/config";
22
import { resolve } from "path";
33
import packageJson from "../package.json" with { type: "json" };
4-
import { aliasHttpClientToAxiosSource } from "./vitest-utils";
4+
import { aliasHttpClientToAxiosSource, mockSafeguards } from "./vitest-utils";
55

66
const isAxios = process.env.TRANSPORT === "axios";
77

@@ -24,6 +24,9 @@ export default defineConfig({
2424
test: {
2525
globals: true,
2626
environment: "node",
27+
// Shared with the browser config: `test/unit` runs under both, so the
28+
// cleanup guarantee must not depend on which runner picks up the file.
29+
...mockSafeguards,
2730
coverage: {
2831
provider: "v8",
2932
reporter: ["text", "html", "lcov"],

test/unit/contract/client_from.test.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,12 @@
1-
import { describe, it, afterEach, expect, vi } from "vitest";
1+
import { describe, it, beforeEach, expect, vi } from "vitest";
22
import { concatUint8Arrays, stringToUint8Array } from "uint8array-extras";
33
import * as StellarSdk from "../../../src/index.js";
44

55
import { serverUrl } from "../../constants";
66

7-
// `Client.from` constructs its own `Server` internally, so we cannot spy on a
8-
// server instance we create here. Instead we mock the http-client factory that
9-
// the internal `Server` uses, which lets the real code path
10-
// (`getContractInstance`, then `getContractWasmByHash` for Wasm contracts) run
11-
// against controlled JSON-RPC responses.
12-
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
13-
14-
vi.mock("../../../src/rpc/axios.js", async (importActual) => {
15-
const actual =
16-
await importActual<typeof import("../../../src/rpc/axios.js")>();
17-
return {
18-
...actual,
19-
createHttpClient: () => ({ post: mockPost }),
20-
};
21-
});
22-
23-
const { xdr, hash, Contract } = StellarSdk;
7+
const { xdr, hash, Contract, rpc } = StellarSdk;
248
const { Client } = StellarSdk.contract;
9+
const { Server } = rpc;
2510

2611
const networkPassphrase = "Test SDF Network ; September 2015";
2712

@@ -65,8 +50,21 @@ function wasmWithSpec(entries: StellarSdk.xdr.ScSpecEntry[]): Uint8Array {
6550
}
6651

6752
describe("contract.Client.from", () => {
68-
afterEach(() => {
69-
vi.clearAllMocks();
53+
let server: any;
54+
let mockPost: any;
55+
56+
// `Client.from` accepts a pre-built `Server` via `options.server`, so spying
57+
// on that instance's http client lets the real code path
58+
// (`getContractInstance`, then `getContractWasmByHash` for wasm contracts)
59+
// run against controlled JSON-RPC responses without mocking a module.
60+
beforeEach(() => {
61+
server = new Server(serverUrl);
62+
// The default throws rather than calling through: `vi.spyOn` keeps the real
63+
// implementation, so once the queued `mockResolvedValueOnce` responses run
64+
// out an unexpected call would otherwise issue a real HTTP request.
65+
mockPost = vi.spyOn(server.httpClient, "post").mockImplementation(() => {
66+
throw new Error("unexpected RPC call");
67+
});
7068
});
7169

7270
const contractId = "CCN57TGC6EXFCYIQJ4UCD2UDZ4C3AQCHVMK74DGZ3JYCA5HD4BY7FNPC";
@@ -155,10 +153,13 @@ describe("contract.Client.from", () => {
155153
contractId,
156154
networkPassphrase,
157155
rpcUrl: serverUrl,
156+
server,
158157
});
159158

160159
expect(client).toBeInstanceOf(Client);
161160
expect(client.spec.funcs().length).toBeGreaterThan(0);
161+
// The instance lookup, then the wasm fetch.
162+
expect(mockPost).toHaveBeenCalledTimes(2);
162163
});
163164
});
164165

@@ -191,6 +192,7 @@ describe("contract.Client.from", () => {
191192
contractId,
192193
networkPassphrase,
193194
rpcUrl: serverUrl,
195+
server,
194196
});
195197

196198
expect(client).toBeInstanceOf(Client);
@@ -204,6 +206,7 @@ describe("contract.Client.from", () => {
204206
]) {
205207
expect(typeof (client as any)[method]).toBe("function");
206208
}
209+
expect(mockPost).toHaveBeenCalledTimes(1);
207210
});
208211
});
209212
});

0 commit comments

Comments
 (0)