@uniflowed/test 0.0.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app-browser.js ADDED
@@ -0,0 +1,6 @@
1
+ // @flow
2
+ export function createTestApp(_options?: mixed): empty {
3
+ throw new Error(
4
+ "createTestApp runs in a Node test worker. Run this test with uf test, and use createBrowser() to check its RSC hydration in Chromium.",
5
+ );
6
+ }
package/app.js ADDED
@@ -0,0 +1,175 @@
1
+ // @flow
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ export type AppTestOptions = {|
8
+ readonly root: string | URL,
9
+ readonly binary?: string,
10
+ readonly env?: { readonly [string]: string },
11
+ readonly timeoutMs?: number,
12
+ |};
13
+
14
+ export type AppRequest = {|
15
+ readonly method?: string,
16
+ readonly headers?: { readonly [string]: string },
17
+ readonly body?: string,
18
+ readonly signal?: AbortSignal,
19
+ |};
20
+
21
+ export type TestApp = {|
22
+ /** The live URL, including after an environment-triggered restart. */
23
+ readonly origin: () => string,
24
+ readonly fetch: (pathname: string, options?: AppRequest) => Promise<Response>,
25
+ readonly render: (pathname: string, options?: AppRequest) => Promise<Response>,
26
+ /** The actual React Flight stream, with request headers and Suspense intact. */
27
+ readonly flight: (pathname: string, options?: AppRequest) => Promise<Response>,
28
+ readonly events: () => $ReadOnlyArray<{ readonly [string]: mixed }>,
29
+ readonly close: () => Promise<void>,
30
+ |};
31
+
32
+ /** Start the application's real web pipeline in an isolated process for `uf test`. */
33
+ export async function createTestApp(options: AppTestOptions): Promise<TestApp> {
34
+ const root = path.resolve(
35
+ options.root instanceof URL ? fileURLToPath(options.root.href) : options.root,
36
+ );
37
+ const timeout = options.timeoutMs ?? 30000;
38
+ if (!Number.isFinite(timeout) || timeout <= 0)
39
+ throw new TypeError("test app timeout must be positive");
40
+ const child = spawn(
41
+ options.binary ?? process.env.UF_BINARY ?? "uf",
42
+ [
43
+ "--cwd",
44
+ root,
45
+ "dev",
46
+ "--json",
47
+ "--parent-pipe",
48
+ "--target",
49
+ "web",
50
+ "--host",
51
+ "127.0.0.1",
52
+ "--port",
53
+ "0",
54
+ ],
55
+ { env: { ...process.env, ...options.env }, stdio: ["pipe", "pipe", "pipe"] },
56
+ );
57
+ const events: Array<{ readonly [string]: mixed }> = [];
58
+ let transcript = "";
59
+ let origin = "";
60
+ let ended = false;
61
+ const exited = new Promise<void>((resolve) =>
62
+ // The CLI exits before its Vite driver's inherited streams close. Wait for
63
+ // those streams too, so close() includes server and diagnostic cleanup.
64
+ child.once("close", () => {
65
+ ended = true;
66
+ resolve();
67
+ }),
68
+ );
69
+ let closing = null;
70
+ const close = (): Promise<void> => {
71
+ if (closing != null) return closing;
72
+ closing = (async () => {
73
+ if (ended) return;
74
+ child.stdin?.end();
75
+ const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
76
+ try {
77
+ await exited;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ })();
82
+ return closing;
83
+ };
84
+ child.stderr?.on("data", (chunk) => {
85
+ transcript = (transcript + String(chunk)).slice(-16384);
86
+ });
87
+ const lines = createInterface({ input: child.stdout });
88
+ try {
89
+ await new Promise<void>((resolve, reject) => {
90
+ const timer = setTimeout(
91
+ () => reject(new Error(`test app did not start within ${timeout}ms\n${transcript}`)),
92
+ timeout,
93
+ );
94
+ const fail = (error: mixed) => {
95
+ clearTimeout(timer);
96
+ reject(error);
97
+ };
98
+ child.once("error", (error) => {
99
+ ended = true;
100
+ fail(error);
101
+ });
102
+ child.once("exit", (code) =>
103
+ fail(new Error(`test app exited (${String(code)})\n${transcript}`)),
104
+ );
105
+ lines.on("line", (line) => {
106
+ let event;
107
+ try {
108
+ event = JSON.parse(line);
109
+ } catch {
110
+ transcript = (transcript + line + "\n").slice(-16384);
111
+ return;
112
+ }
113
+ events.push(event);
114
+ if (events.length > 200) events.shift();
115
+ if (event.event === "listening" && typeof event.local?.[0] === "string") {
116
+ origin = new URL(event.local[0]).origin;
117
+ clearTimeout(timer);
118
+ resolve();
119
+ } else if (event.event === "error") {
120
+ fail(new Error(`test app: ${String(event.message)}`));
121
+ }
122
+ });
123
+ });
124
+ } catch (error) {
125
+ await close();
126
+ throw error;
127
+ }
128
+ function address(pathname: string): URL {
129
+ if (ended || closing != null) throw new Error("test app is closed");
130
+ if (!pathname.startsWith("/") || pathname.startsWith("//") || pathname.includes("\\")) {
131
+ throw new TypeError("test app needs an absolute path on its own origin");
132
+ }
133
+ const url = new URL(pathname, origin);
134
+ if (url.origin !== origin) throw new TypeError("test app cannot fetch another origin");
135
+ return url;
136
+ }
137
+ const request = async (url: URL, init?: AppRequest): Promise<Response> => {
138
+ const controller = new AbortController();
139
+ const timer = setTimeout(() => controller.abort(), timeout);
140
+ try {
141
+ return await fetch(url.href, {
142
+ ...init,
143
+ headers: { ...init?.headers },
144
+ signal:
145
+ init?.signal == null
146
+ ? controller.signal
147
+ : AbortSignal.any([init.signal, controller.signal]),
148
+ });
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ };
153
+ return {
154
+ origin: () => origin,
155
+ fetch: async (pathname, init) => request(address(pathname), init),
156
+ render: async (pathname, init) => {
157
+ const headers = new Headers({ ...init?.headers });
158
+ headers.set("accept", "text/html");
159
+ return request(address(pathname), { ...init, headers: Object.fromEntries(headers) });
160
+ },
161
+ flight: async (pathname, init) => {
162
+ const url = address(pathname);
163
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/__uf.flight`;
164
+ const response = await request(url, init);
165
+ if (!(response.headers.get("content-type") ?? "").includes("text/x-component")) {
166
+ throw new Error(
167
+ `test app returned ${response.status} without a Flight stream; enable RSC for this route`,
168
+ );
169
+ }
170
+ return response;
171
+ },
172
+ events: () => [...events],
173
+ close,
174
+ };
175
+ }
@@ -0,0 +1,334 @@
1
+ // @flow
2
+ //
3
+ // The process `uf test --browser` fans work out to.
4
+ //
5
+ // `./worker.js` runs a test file. This runs a *browser* that runs a test file,
6
+ // and from `uf`'s side the two are the same thing: a process started the same
7
+ // way, given a request per line on stdin, answering with an event per line on
8
+ // stdout, bounded by the same wall clock and replaced the same way when it
9
+ // stops answering. `crates/uf_test/src/host.rs` does not branch on which of
10
+ // the two it is talking to, and that is the whole design — the browser is
11
+ // another host, not a second runner.
12
+ //
13
+ // uf ──stdin──▶ this process ──HTTP──▶ a page in a real browser
14
+ // ◀─stdout── ◀─HTTP──
15
+ //
16
+ // # Why there is a process in the middle at all
17
+ //
18
+ // A page cannot read a pipe. Somebody has to hold the browser's process
19
+ // handle, serve it modules, and turn what it says into what `uf` reads, and
20
+ // that somebody has to be a JavaScript host because resolving a bare specifier
21
+ // and transforming Flow are already solved there. So the driver is Node, the
22
+ // browser is the host under test, and the split is named in `docs/hosts.md`
23
+ // rather than left for a reader to infer from a stack trace.
24
+ //
25
+ // # Browser control
26
+ //
27
+ // Chromium is selected by `UF_BROWSER` or the CLI's browser discovery. Tests
28
+ // use `@uniflowed/test/browser` to create isolated pages, send trusted input,
29
+ // navigate and compare screenshots. The controller speaks CDP over a private
30
+ // pipe; the harness page relays bounded commands through its authenticated
31
+ // loopback endpoint. Closing the worker's pipe also terminates Chromium.
32
+ //
33
+ // # How this fails
34
+ //
35
+ // Loudly, by name, in three places. The browser is not there — refused in
36
+ // Rust, before a process is started. The browser starts and never asks for
37
+ // work — refused here, with whatever it wrote to stderr, because a browser
38
+ // that will not open a page is not a suite that failed. The browser goes away
39
+ // mid-run — the stream ends, and `uf` reports a host that died, which is the
40
+ // path Node workers already take.
41
+
42
+ import { spawn } from "node:child_process";
43
+ import { createInterface } from "node:readline";
44
+ import { mkdtempSync, rmSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import path from "node:path";
47
+
48
+ import { inSourceTests, sharedService } from "@uniflowed/host/transform";
49
+
50
+ import { create } from "./internal/browser/server.js";
51
+ import { connectPipe } from "./internal/browser/cdp.js";
52
+
53
+ /** What `uf` sends for one file. */
54
+ type Request = {|
55
+ readonly file: string,
56
+ readonly filter?: string | null,
57
+ readonly timeoutMs?: number,
58
+ readonly generation?: number,
59
+ |};
60
+
61
+ /**
62
+ * How long a browser is given to open the page before the run is refused.
63
+ *
64
+ * Generous against a cold Chromium with a cold profile, which is about seven
65
+ * seconds on a laptop and much worse in a busy container, and deliberately
66
+ * *shorter than `uf_test`'s browser file budget* (`BROWSER_FILE_TIMEOUT`, two
67
+ * minutes).
68
+ * That order is the whole point: `uf` is already holding a stopwatch on the
69
+ * first file while this is happening, and whichever of the two fires first is
70
+ * what the report says. A browser that will not start should be reported as a
71
+ * browser that will not start, with its own output attached — not as a file
72
+ * that timed out, which is a sentence about tests that were never reached.
73
+ */
74
+ const START_TIMEOUT_MS = 110_000;
75
+
76
+ /**
77
+ * The switches a headless run needs, and what each is for.
78
+ *
79
+ * Kept short on purpose. Every flag here is a decision about the browser the
80
+ * tests are measured in, and a long list is a long list of ways this page
81
+ * differs from the application's — which is the difference browser mode exists
82
+ * to remove.
83
+ */
84
+ function browserArguments(profile: string, url: string): Array<string> {
85
+ return [
86
+ // The modern headless, which is the same renderer as a windowed browser.
87
+ // The old one was a separate binary with its own layout quirks, and a
88
+ // layout answer from it would not have been an answer about Chrome.
89
+ "--headless=new",
90
+ // What makes the browser die when this process does, and the only reason
91
+ // this mode names a debugging switch at all.
92
+ //
93
+ // `uf` kills a worker that misses its deadline, and a killed process runs
94
+ // no exit handler — so without this, a timed-out file would leave a browser
95
+ // running with nobody to stop it, and a suite that leaks one browser per
96
+ // timeout eventually cannot run. Chrome treats the pipe as its lifeline:
97
+ // when the file descriptors close, for any reason, it shuts itself down.
98
+ //
99
+ // The same private pipe carries CDP commands for pages created by tests.
100
+ // It exposes no debugging port to other local browser origins.
101
+ //
102
+ // The tab closing is not enough on its own, which is what this replaces: a
103
+ // page that calls `window.close()` frees the renderers and leaves the
104
+ // browser process running (measured on Chrome 148).
105
+ "--remote-debugging-pipe",
106
+ // A scratch profile per run: no extensions, no saved state, no policy from
107
+ // the developer's own browser leaking into what the suite measures.
108
+ `--user-data-dir=${profile}`,
109
+ // The scratch profile stores no credentials, so keep Chrome off desktop
110
+ // keyring backends that can hang behind a broken CI DBus session before
111
+ // the first page request.
112
+ "--password-store=basic",
113
+ "--no-first-run",
114
+ "--no-default-browser-check",
115
+ // Keep startup deterministic in CI. These cut services Chrome may start
116
+ // before the first page request: extensions, sync, updater probes, and
117
+ // background network clients such as GCM. None of them changes layout or
118
+ // module execution, which is what browser mode measures.
119
+ "--disable-background-networking",
120
+ "--disable-client-side-phishing-detection",
121
+ "--disable-component-update",
122
+ "--disable-default-apps",
123
+ "--disable-domain-reliability",
124
+ "--disable-extensions",
125
+ "--disable-sync",
126
+ "--disable-features=AutofillServerCommunication,BackgroundFetch,BackgroundSync,CertificateTransparencyComponentUpdater,DialMediaRouteProvider,MediaRouter,NotificationTriggers,OptimizationHints,PushMessaging,Translate",
127
+ "--metrics-recording-only",
128
+ // Nothing here paints, and a GPU process is one more thing to fail in a
129
+ // container.
130
+ "--disable-gpu",
131
+ // `/dev/shm` is 64 MB in most containers, which is where a renderer dies
132
+ // in CI and nowhere else.
133
+ "--disable-dev-shm-usage",
134
+ "--disable-background-timer-throttling",
135
+ "--disable-renderer-backgrounding",
136
+ url,
137
+ ];
138
+ }
139
+
140
+ /**
141
+ * Write one event, exactly as `./worker.js` does.
142
+ *
143
+ * The raw stream, taken before anything in this process could have replaced
144
+ * it. Nothing here captures `console`: the driver runs no test code, and the
145
+ * printing that has to be captured happens in the page.
146
+ */
147
+ const emit = (line: string) => {
148
+ process.stdout.write(line);
149
+ };
150
+
151
+ function write(event: { readonly [string]: mixed }): void {
152
+ emit(`${JSON.stringify(event)}\n`);
153
+ }
154
+
155
+ /** Report that the run cannot happen, against the file that asked for it. */
156
+ function refuse(message: string, generation: number): void {
157
+ write({ event: "file", status: "run-failed", message, generation });
158
+ }
159
+
160
+ async function main(): Promise<void> {
161
+ const root = process.env.UF_PROJECT_ROOT ?? process.cwd();
162
+ const browser = process.env.UF_BROWSER;
163
+ const service = sharedService(root);
164
+
165
+ let controller = null;
166
+ const server = await create({
167
+ root,
168
+ browserCommand: async (id, method, args) => {
169
+ if (controller == null) throw new Error("browser driver is not ready");
170
+ return controller.command(id, method, args);
171
+ },
172
+ transform: async (id, code) => {
173
+ // The same three the Node loader passes (`packages/host/internal/
174
+ // node-hooks.js`), so a module means the same thing on both hosts.
175
+ // `inSourceTests` is the one that would be easy to forget and expensive
176
+ // to: without it `import.meta.uf.test` compiles to `void 0`, every
177
+ // in-source block is shaken out, and a browser run of a project that
178
+ // writes them reports fewer tests than the Node run of the same files —
179
+ // silently, and with a green summary.
180
+ const result = await service.transform(id, code, {
181
+ development: true,
182
+ sourceMap: true,
183
+ inSourceTests: inSourceTests(),
184
+ });
185
+ if (result == null) return null;
186
+ // The map is appended rather than served beside the module: a browser
187
+ // reads `sourceMappingURL` from the end of the file, and a data URL is
188
+ // one fewer route and one fewer round trip. What it buys is a stack
189
+ // frame in a failure that names the line the author wrote — the same
190
+ // thing `--enable-source-maps` buys a Node worker.
191
+ const map = result.map;
192
+ if (map == null) return result.code;
193
+ const encoded = Buffer.from(typeof map === "string" ? map : JSON.stringify(map)).toString(
194
+ "base64",
195
+ );
196
+ return `${result.code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${encoded}\n`;
197
+ },
198
+ warn: (message) => {
199
+ process.stderr.write(`[uf] ${message}\n`);
200
+ },
201
+ });
202
+
203
+ server.onEvent((event) => {
204
+ if (event.event === "file" && controller != null) {
205
+ void controller.closePages().then(
206
+ () => write(event),
207
+ (error) => refuse(String(error), Number(event.generation ?? 0)),
208
+ );
209
+ } else write(event);
210
+ });
211
+
212
+ if (browser == null || browser === "") {
213
+ // Belt and braces: `uf` refuses before starting this process, so reaching
214
+ // here means somebody ran the driver by hand.
215
+ refuse(
216
+ "`@uniflowed/test/browser-worker.js` was started with no UF_BROWSER. It is not a command to run directly; `uf test --browser` finds a browser and names it here.",
217
+ 0,
218
+ );
219
+ await server.close();
220
+ return;
221
+ }
222
+
223
+ const profile = mkdtempSync(path.join(tmpdir(), "uf-browser-"));
224
+ // Five descriptors, because `--remote-debugging-pipe` reads 3 and writes 4.
225
+ // The controller below exchanges null-delimited CDP messages on them.
226
+ const child = spawn(browser, browserArguments(profile, server.url), {
227
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
228
+ });
229
+ controller = connectPipe(child, { root });
230
+ // Kept, not printed. A Chromium writes a dozen lines about GPU probing and
231
+ // Vulkan on a healthy start, and forwarding those to a passing run's report
232
+ // would be noise; they are the whole of the evidence when it does not start,
233
+ // and that is when they are shown.
234
+ let noise = "";
235
+ const keep = (chunk: mixed) => {
236
+ if (noise.length < 8 * 1024) noise += String(chunk);
237
+ };
238
+ child.stdout?.on("data", keep);
239
+ child.stderr?.on("data", keep);
240
+
241
+ let started = false;
242
+ const startFailure = await new Promise<string | null>((resolve) => {
243
+ const deadline = setTimeout(() => {
244
+ resolve(`the browser did not open the page within ${String(START_TIMEOUT_MS / 1000)}s`);
245
+ }, START_TIMEOUT_MS);
246
+ // Polled rather than pushed: the page's first request *is* the signal, and
247
+ // the server already knows when it arrives. A `Page.loadEventFired` would
248
+ // be the protocol this mode does not speak.
249
+ const poll: IntervalID = setInterval(() => {
250
+ if (!server.connected()) return;
251
+ clearInterval(poll);
252
+ clearTimeout(deadline);
253
+ started = true;
254
+ resolve(null);
255
+ }, 25);
256
+ child.on("exit", (code: mixed) => {
257
+ if (started) return;
258
+ clearInterval(poll);
259
+ clearTimeout(deadline);
260
+ resolve(`the browser exited (${String(code)}) before opening the page`);
261
+ });
262
+ child.on("error", (error: Error) => {
263
+ clearInterval(poll);
264
+ clearTimeout(deadline);
265
+ resolve(`the browser could not be started: ${error.message}`);
266
+ });
267
+ });
268
+
269
+ const stop = () => {
270
+ child.kill("SIGKILL");
271
+ try {
272
+ rmSync(profile, { recursive: true, force: true });
273
+ } catch {
274
+ // A scratch profile that outlives the run is litter in the system
275
+ // temporary directory, not a reason to fail a suite.
276
+ }
277
+ };
278
+
279
+ if (startFailure != null) {
280
+ stop();
281
+ await server.close();
282
+ // The first file's request is what `uf` is waiting on, so the refusal is
283
+ // reported against it — every subsequent file gets the same message from a
284
+ // fresh driver, which is the same shape as a worker that cannot spawn.
285
+ const trailer = noise.trim() === "" ? "" : `\n${noise.trim()}`;
286
+ refuse(`\`${browser}\`: ${startFailure}.${trailer}`, 0);
287
+ // The stream ends here, which `uf` reads as a host that died. That is
288
+ // exactly what happened, and it is what makes `uf` start a fresh driver
289
+ // for the next file rather than send it to a browser that is not there.
290
+ return;
291
+ }
292
+
293
+ createInterface({ input: process.stdin }).on("line", (line) => {
294
+ if (line.trim() === "") return;
295
+ let request: Request;
296
+ try {
297
+ request = JSON.parse(line);
298
+ } catch (error) {
299
+ write({
300
+ event: "file",
301
+ status: "run-failed",
302
+ message: `malformed request: ${String(error)}`,
303
+ generation: 0,
304
+ });
305
+ return;
306
+ }
307
+ // Handed straight over. `uf` sends one file at a time and waits for its
308
+ // `file` event, and the page runs one file per load, so there is no queue
309
+ // to keep here — the two ends already agree on the shape.
310
+ server.offer({
311
+ file: request.file,
312
+ filter: request.filter ?? null,
313
+ timeoutMs: request.timeoutMs ?? 5000,
314
+ generation: request.generation ?? 0,
315
+ });
316
+ });
317
+
318
+ process.stdin.on("close", () => {
319
+ void server.close().then(() => {
320
+ stop();
321
+ service.close();
322
+ process.exit(0);
323
+ });
324
+ });
325
+ }
326
+
327
+ // A driver that throws has no file to blame, so it says so against the request
328
+ // `uf` is waiting on and lets the stream end. Silence here would be a run that
329
+ // waits for a deadline.
330
+ main().catch((error: mixed) => {
331
+ const reason = error instanceof Error ? error.message : String(error);
332
+ refuse(`\`uf test --browser\` could not start: ${reason}`, 0);
333
+ process.exit(1);
334
+ });
package/browser.js ADDED
@@ -0,0 +1,59 @@
1
+ // @flow
2
+ import { createTransport } from "./internal/browser/transport.js";
3
+
4
+ export type Viewport = {|
5
+ readonly width: number,
6
+ readonly height: number,
7
+ readonly deviceScaleFactor?: number,
8
+ |};
9
+ export type ScreenshotResult = {|
10
+ readonly baseline: string,
11
+ readonly updated: boolean,
12
+ readonly differentPixels: number,
13
+ |};
14
+ export type BrowserOptions = {|
15
+ readonly executable?: string,
16
+ readonly timeoutMs?: number,
17
+ |};
18
+ export type TestPage = {|
19
+ readonly visit: (url: string) => Promise<void>,
20
+ readonly setContent: (html: string) => Promise<void>,
21
+ readonly waitFor: (selector: string) => Promise<void>,
22
+ readonly text: (selector: string) => Promise<string | null>,
23
+ readonly value: (selector: string) => Promise<string | null>,
24
+ readonly url: () => Promise<string>,
25
+ readonly click: (selector: string) => Promise<void>,
26
+ readonly fill: (selector: string, text: string) => Promise<void>,
27
+ readonly press: (key: string) => Promise<void>,
28
+ readonly tap: (selector: string) => Promise<void>,
29
+ readonly viewport: (viewport: Viewport) => Promise<void>,
30
+ readonly screenshot: (
31
+ name: string,
32
+ options?: {| readonly threshold?: number, readonly baselines?: string |},
33
+ ) => Promise<ScreenshotResult>,
34
+ readonly events: () => Promise<$ReadOnlyArray<{ readonly [string]: mixed }>>,
35
+ readonly close: () => Promise<void>,
36
+ |};
37
+
38
+ /** An isolated Chromium page; the test harness stays alive across navigations. */
39
+ export async function createBrowser(options?: BrowserOptions): Promise<TestPage> {
40
+ const transport = await createTransport(options ?? {});
41
+ const call = (method: string, args: $ReadOnlyArray<mixed> = []) =>
42
+ transport.command(transport.id, method, args);
43
+ return {
44
+ visit: (url) => call("visit", [url]),
45
+ setContent: (html) => call("setContent", [html]),
46
+ waitFor: (selector) => call("waitFor", [selector]),
47
+ text: (selector) => call("text", [selector]),
48
+ value: (selector) => call("value", [selector]),
49
+ url: () => call("url"),
50
+ click: (selector) => call("click", [selector]),
51
+ fill: (selector, text) => call("fill", [selector, text]),
52
+ press: (key) => call("press", [key]),
53
+ tap: (selector) => call("tap", [selector]),
54
+ viewport: (viewport) => call("viewport", [viewport]),
55
+ screenshot: (name, options) => call("screenshot", [name, options]),
56
+ events: () => call("events"),
57
+ close: transport.close,
58
+ };
59
+ }