@specific.dev/spectest 0.102.0 → 0.104.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/README.md +46 -0
- package/dist/browser.d.ts +14 -1
- package/dist/browser.js +89 -31
- package/dist/daemon.js +2 -7
- package/dist/fake-websocket.d.ts +16 -0
- package/dist/fake-websocket.js +1 -0
- package/dist/harness/fake-websocket-connection.d.ts +5 -0
- package/dist/harness/fake-websocket-connection.js +58 -0
- package/dist/harness/fake-websocket.d.ts +4 -0
- package/dist/harness/fake-websocket.js +32 -0
- package/dist/harness/ingress-server.d.ts +5 -1
- package/dist/harness/ingress-server.js +14 -1
- package/dist/harness/microphone-fixture.d.ts +13 -0
- package/dist/harness/microphone-fixture.js +67 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
- package/src/browser-timezone.test.ts +105 -0
- package/src/browser.ts +83 -30
- package/src/daemon.ts +2 -11
- package/src/fake-websocket.ts +17 -0
- package/src/harness/fake-websocket-connection.ts +54 -0
- package/src/harness/fake-websocket.test.ts +219 -0
- package/src/harness/fake-websocket.ts +39 -0
- package/src/harness/ingress-server.ts +15 -2
- package/src/harness/microphone-fixture.ts +71 -0
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +143 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readRaw } from "./inspect.js";
|
|
3
|
+
import {
|
|
4
|
+
acquirePersistentBrowser,
|
|
5
|
+
openBrowser,
|
|
6
|
+
prewarmViewPool,
|
|
7
|
+
type Browser,
|
|
8
|
+
} from "./browser.js";
|
|
9
|
+
|
|
10
|
+
interface Settings {
|
|
11
|
+
timezone: string;
|
|
12
|
+
locale: string;
|
|
13
|
+
language: string;
|
|
14
|
+
acceptLanguage: string;
|
|
15
|
+
winterOffset: number;
|
|
16
|
+
summerOffset: number;
|
|
17
|
+
number: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function settings(browser: Browser): Promise<Settings> {
|
|
21
|
+
return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("browser timezone and locale", () => {
|
|
25
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
server = Bun.serve({
|
|
28
|
+
port: 0,
|
|
29
|
+
hostname: "127.0.0.1",
|
|
30
|
+
fetch: request => new Response(`<script>
|
|
31
|
+
window.settings = {
|
|
32
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
33
|
+
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
|
34
|
+
language: navigator.language,
|
|
35
|
+
acceptLanguage: ${JSON.stringify(request.headers.get("accept-language"))},
|
|
36
|
+
winterOffset: new Date("2026-01-15T12:00:00Z").getTimezoneOffset(),
|
|
37
|
+
summerOffset: new Date("2026-07-15T12:00:00Z").getTimezoneOffset(),
|
|
38
|
+
number: new Intl.NumberFormat().format(1234.5),
|
|
39
|
+
};
|
|
40
|
+
</script>`, { headers: { "content-type": "text/html" } }),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterAll(() => server.stop(true));
|
|
44
|
+
|
|
45
|
+
test("applies both options before initial navigation and retains them after reload", async () => {
|
|
46
|
+
const browser = await openBrowser({ timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
47
|
+
try {
|
|
48
|
+
const initial = await settings(browser);
|
|
49
|
+
expect(initial).toMatchObject({
|
|
50
|
+
timezone: "Europe/Stockholm", locale: "sv-SE", language: "sv-SE",
|
|
51
|
+
winterOffset: -60, summerOffset: -120, number: "1\u00a0234,5",
|
|
52
|
+
});
|
|
53
|
+
expect(initial.acceptLanguage.split(",")[0]).toBe("sv-SE");
|
|
54
|
+
await browser.reload();
|
|
55
|
+
expect(await settings(browser)).toEqual(initial);
|
|
56
|
+
} finally { await browser.close(); }
|
|
57
|
+
}, 20000);
|
|
58
|
+
|
|
59
|
+
test.each([
|
|
60
|
+
{ timezone: "Europe/Stockholm" },
|
|
61
|
+
{ locale: "sv-SE" },
|
|
62
|
+
])("bypasses the default view pool for %j without changing defaults", async options => {
|
|
63
|
+
const baseline = await openBrowser({ url: server.url.href });
|
|
64
|
+
let defaults: Settings;
|
|
65
|
+
try { defaults = await settings(baseline); } finally { await baseline.close(); }
|
|
66
|
+
await prewarmViewPool();
|
|
67
|
+
const browser = await openBrowser({ ...options, url: server.url.href });
|
|
68
|
+
try {
|
|
69
|
+
const actual = await settings(browser);
|
|
70
|
+
expect(actual.timezone).toBe(options.timezone ?? defaults.timezone);
|
|
71
|
+
expect(actual.language).toBe(options.locale ?? defaults.language);
|
|
72
|
+
} finally { await browser.close(); }
|
|
73
|
+
const pooled = await openBrowser({ url: server.url.href });
|
|
74
|
+
try { expect(await settings(pooled)).toEqual(defaults); } finally { await pooled.close(); }
|
|
75
|
+
}, 20000);
|
|
76
|
+
|
|
77
|
+
test("named sessions are isolated and reattachment keeps first-call settings", async () => {
|
|
78
|
+
const a = await acquirePersistentBrowser("regional-a", { timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
79
|
+
try {
|
|
80
|
+
const b = await acquirePersistentBrowser("regional-b", { timezone: "America/New_York", locale: "en-US", url: server.url.href });
|
|
81
|
+
try {
|
|
82
|
+
expect(await settings(b.browser)).toMatchObject({ timezone: "America/New_York", language: "en-US", winterOffset: 300, summerOffset: 240 });
|
|
83
|
+
const original = await settings(a.browser);
|
|
84
|
+
expect(original).toMatchObject({ timezone: "Europe/Stockholm", language: "sv-SE" });
|
|
85
|
+
await a.detach();
|
|
86
|
+
const attached = await acquirePersistentBrowser("regional-a", { timezone: "UTC", locale: "en-US" });
|
|
87
|
+
expect(attached.attached).toBe(true);
|
|
88
|
+
await attached.browser.reload();
|
|
89
|
+
expect(await settings(attached.browser)).toEqual(original);
|
|
90
|
+
await attached.detach();
|
|
91
|
+
} finally { await b.browser.close(); }
|
|
92
|
+
} finally { await a.browser.close(); }
|
|
93
|
+
}, 20000);
|
|
94
|
+
|
|
95
|
+
test("mobile framing retains regional settings", async () => {
|
|
96
|
+
const browser = await openBrowser({ frame: "mobile", timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
97
|
+
try {
|
|
98
|
+
expect(await settings(browser)).toMatchObject({ timezone: "Europe/Stockholm", language: "sv-SE", winterOffset: -60 });
|
|
99
|
+
} finally { await browser.close(); }
|
|
100
|
+
}, 20000);
|
|
101
|
+
|
|
102
|
+
test("rejects an invalid timezone", async () => {
|
|
103
|
+
await expect(openBrowser({ timezone: "Invalid/Timezone" })).rejects.toThrow(/timezone/i);
|
|
104
|
+
}, 20000);
|
|
105
|
+
});
|
package/src/browser.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { fileURLToPath } from "node:url";
|
|
|
35
35
|
|
|
36
36
|
import { generateId } from "./ids.js";
|
|
37
37
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
38
|
+
import { prepareMicrophoneFixture, type MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
39
|
+
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
38
40
|
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
39
41
|
import type { DesktopChrome } from "./desktop.js";
|
|
40
42
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
@@ -91,6 +93,17 @@ export interface BrowserOptions {
|
|
|
91
93
|
frame?: "browser" | "mobile";
|
|
92
94
|
/** Initial URL to navigate to before the constructor returns. */
|
|
93
95
|
url?: string;
|
|
96
|
+
/** IANA timezone, e.g. "Europe/Stockholm". Defaults to the host timezone.
|
|
97
|
+
* Set on first creation; inherited sessions keep their original timezone. */
|
|
98
|
+
timezone?: string;
|
|
99
|
+
/** Locale, e.g. "sv-SE", for navigator.language, Accept-Language and
|
|
100
|
+
* date/number formatting. Omit to use the browser's default locale.
|
|
101
|
+
* Set on first creation; inherited sessions keep their original locale. */
|
|
102
|
+
locale?: string;
|
|
103
|
+
/** Feed a WAV fixture through Chromium's native microphone capture.
|
|
104
|
+
* Set on the session's first creation; inherited sessions keep their source.
|
|
105
|
+
* Fixture sessions use a dedicated Chromium process for audio isolation. */
|
|
106
|
+
microphone?: MicrophoneFixture;
|
|
94
107
|
/**
|
|
95
108
|
* Script installed (before the session's first navigation) to run in every
|
|
96
109
|
* document loaded from now on, BEFORE the document's own scripts — the
|
|
@@ -406,8 +419,8 @@ export interface MobileBackend extends Browser {
|
|
|
406
419
|
// What the app gets is Chromium's own synthetic devices: the microphone is a
|
|
407
420
|
// generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
|
|
408
421
|
// WebRTC and AudioContext analysis all work on it), the camera a rolling
|
|
409
|
-
// test pattern at 640x480.
|
|
410
|
-
// in;
|
|
422
|
+
// test pattern at 640x480. A microphone fixture adds Chromium's file-capture
|
|
423
|
+
// flag in a dedicated process; getUserMedia and track behavior stay native.
|
|
411
424
|
//
|
|
412
425
|
// --use-fake-device-for-media-stream: the synthetic camera + microphone.
|
|
413
426
|
// Also what makes `enumerateDevices()` return anything at all.
|
|
@@ -442,14 +455,16 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
442
455
|
// Shared Chromium (playwright-core)
|
|
443
456
|
// ────────────────────────────────────────────────────────────────────────
|
|
444
457
|
|
|
445
|
-
//
|
|
446
|
-
// for the daemon's lifetime.
|
|
458
|
+
// Default sessions share one Chromium per daemon, launched lazily and kept
|
|
459
|
+
// for the daemon's lifetime. Fixture sessions own a process because the audio
|
|
460
|
+
// source is a process-wide flag. Both ride snapshots: the process, playwright's
|
|
447
461
|
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
448
462
|
// and resume in each fork (like dockerd and the daemon itself). Profile
|
|
449
463
|
// state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
|
|
450
464
|
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
451
465
|
// runs a temp --user-data-dir.
|
|
452
466
|
let PW_BROWSER: PlaywrightBrowser | null = null;
|
|
467
|
+
const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
|
|
453
468
|
// Every view gets its OWN context, desktop included. A context is the cookie
|
|
454
469
|
// jar + storage, so one-context-per-view is what makes `close()` mean what
|
|
455
470
|
// the docs promise: the browsing session is destroyed and the next
|
|
@@ -502,20 +517,48 @@ async function newViewContext(
|
|
|
502
517
|
width: number,
|
|
503
518
|
height: number,
|
|
504
519
|
device: DevicePreset | null,
|
|
520
|
+
opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
|
|
505
521
|
): Promise<BrowserContext> {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
522
|
+
const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
|
|
523
|
+
let browser: PlaywrightBrowser | undefined;
|
|
524
|
+
try {
|
|
525
|
+
browser = fixture ? await chromium.launch({
|
|
526
|
+
executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
527
|
+
}) : await ensurePlaywrightBrowser();
|
|
528
|
+
const context = await browser.newContext({
|
|
529
|
+
...(device
|
|
530
|
+
? deviceContextOptions(device)
|
|
531
|
+
: {
|
|
532
|
+
viewport: { width, height },
|
|
533
|
+
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
534
|
+
reducedMotion: "reduce",
|
|
535
|
+
}),
|
|
536
|
+
timezoneId: opts.timezone,
|
|
537
|
+
locale: opts.locale,
|
|
538
|
+
});
|
|
539
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
540
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
541
|
+
if (fixture) {
|
|
542
|
+
const owned = browser;
|
|
543
|
+
VIEW_CLEANUPS.set(context, async () => {
|
|
544
|
+
try { await owned.close(); } finally { await fixture.cleanup(); }
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return context;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (fixture) {
|
|
550
|
+
try { await browser?.close(); } finally { await fixture.cleanup(); }
|
|
551
|
+
}
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function closeViewContext(context: BrowserContext): Promise<void> {
|
|
557
|
+
try { await context.close(); } finally {
|
|
558
|
+
const cleanup = VIEW_CLEANUPS.get(context);
|
|
559
|
+
VIEW_CLEANUPS.delete(context);
|
|
560
|
+
await cleanup?.();
|
|
561
|
+
}
|
|
519
562
|
}
|
|
520
563
|
|
|
521
564
|
/**
|
|
@@ -1276,7 +1319,7 @@ async function createView(width: number, height: number): Promise<PooledView> {
|
|
|
1276
1319
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
1277
1320
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
1278
1321
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
1279
|
-
* width/height bypasses the pool.
|
|
1322
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
1280
1323
|
*/
|
|
1281
1324
|
export async function prewarmViewPool(n = 1): Promise<void> {
|
|
1282
1325
|
for (let i = 0; i < n; i++) {
|
|
@@ -1311,11 +1354,12 @@ export async function openMobileBackend(
|
|
|
1311
1354
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
1312
1355
|
const wantW = device ? device.viewport.width : opts.width ?? 1280;
|
|
1313
1356
|
const wantH = device ? device.viewport.height : opts.height ?? 720;
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
// emulated context anyway.
|
|
1357
|
+
// The pool holds only default-desktop contexts. Context creation options
|
|
1358
|
+
// such as timezone and locale require a fresh context.
|
|
1317
1359
|
const pooled =
|
|
1318
|
-
!device &&
|
|
1360
|
+
!device && opts.microphone === undefined && opts.timezone === undefined &&
|
|
1361
|
+
opts.locale === undefined && wantW === 1280 && wantH === 720
|
|
1362
|
+
? VIEW_POOL.pop() : undefined;
|
|
1319
1363
|
let holder: ViewHolder;
|
|
1320
1364
|
if (pooled) {
|
|
1321
1365
|
holder = {
|
|
@@ -1331,7 +1375,7 @@ export async function openMobileBackend(
|
|
|
1331
1375
|
initScripts: [],
|
|
1332
1376
|
};
|
|
1333
1377
|
} else {
|
|
1334
|
-
holder = await newHolder(wantW, wantH, device);
|
|
1378
|
+
holder = await newHolder(wantW, wantH, device, opts);
|
|
1335
1379
|
}
|
|
1336
1380
|
|
|
1337
1381
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
@@ -1341,11 +1385,14 @@ export async function openMobileBackend(
|
|
|
1341
1385
|
// We deliberately don't forward `opts.url` to the constructor — going
|
|
1342
1386
|
// through our own `navigate()` keeps the recorder log uniform (one
|
|
1343
1387
|
// event per navigation, with timing) and drains rrweb after the load.
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
await backend.goto(opts.url);
|
|
1388
|
+
try {
|
|
1389
|
+
if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
|
|
1390
|
+
if (opts.url !== undefined) await backend.goto(opts.url);
|
|
1391
|
+
return backend;
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
await backend.close();
|
|
1394
|
+
throw err;
|
|
1347
1395
|
}
|
|
1348
|
-
return backend;
|
|
1349
1396
|
}
|
|
1350
1397
|
|
|
1351
1398
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -1400,9 +1447,14 @@ async function newHolder(
|
|
|
1400
1447
|
width: number,
|
|
1401
1448
|
height: number,
|
|
1402
1449
|
device: DevicePreset | null,
|
|
1450
|
+
opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
|
|
1403
1451
|
): Promise<ViewHolder> {
|
|
1404
|
-
const context = await newViewContext(width, height, device);
|
|
1405
|
-
|
|
1452
|
+
const context = await newViewContext(width, height, device, opts);
|
|
1453
|
+
let spawned: SpawnedPage;
|
|
1454
|
+
try { spawned = await spawnPage(context); } catch (err) {
|
|
1455
|
+
await closeViewContext(context);
|
|
1456
|
+
throw err;
|
|
1457
|
+
}
|
|
1406
1458
|
const holder: ViewHolder = {
|
|
1407
1459
|
context,
|
|
1408
1460
|
page: spawned.page,
|
|
@@ -1497,6 +1549,7 @@ export async function acquirePersistentBrowser(
|
|
|
1497
1549
|
device ? device.viewport.width : opts.width ?? 1280,
|
|
1498
1550
|
device ? device.viewport.height : opts.height ?? 720,
|
|
1499
1551
|
device,
|
|
1552
|
+
opts,
|
|
1500
1553
|
);
|
|
1501
1554
|
SHARED_BROWSERS.set(name, holder);
|
|
1502
1555
|
}
|
|
@@ -2163,7 +2216,7 @@ function buildBackend(
|
|
|
2163
2216
|
// The context, not the page, holds the cookies and storage — closing
|
|
2164
2217
|
// only the page would leave the next session signed in as this one.
|
|
2165
2218
|
try {
|
|
2166
|
-
if (buildOpts.ownsContext !== false) await holder.context
|
|
2219
|
+
if (buildOpts.ownsContext !== false) await closeViewContext(holder.context);
|
|
2167
2220
|
} catch {
|
|
2168
2221
|
/* context already gone (browser died) */
|
|
2169
2222
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
unionDockerignore,
|
|
92
92
|
} from "./harness/build-context.js";
|
|
93
93
|
import { startServices } from "./harness/service-graph.js";
|
|
94
|
+
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
94
95
|
import {
|
|
95
96
|
casesMetadata as catalogueCases,
|
|
96
97
|
groupsMetadata as catalogueGroups,
|
|
@@ -2458,17 +2459,7 @@ async function dispatchIngress(
|
|
|
2458
2459
|
// The real answer — a fake handled in-process, or the proxied container.
|
|
2459
2460
|
const upstream = async (current: Request): Promise<Response> => {
|
|
2460
2461
|
if (route.kind === "fake") {
|
|
2461
|
-
|
|
2462
|
-
return await runUninstrumented(() =>
|
|
2463
|
-
route.fake.def.handler(current, route.fake.state, FAKE_CTX),
|
|
2464
|
-
);
|
|
2465
|
-
} catch (err) {
|
|
2466
|
-
const e = err as Error;
|
|
2467
|
-
return new Response(
|
|
2468
|
-
`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
|
|
2469
|
-
{ status: 500, headers: { "content-type": "text/plain" } },
|
|
2470
|
-
);
|
|
2471
|
-
}
|
|
2462
|
+
return dispatchFake(current, server, route.fake.def, route.fake.state, FAKE_CTX);
|
|
2472
2463
|
}
|
|
2473
2464
|
return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
|
|
2474
2465
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A connection accepted by a fake. Text and binary messages retain their type. */
|
|
2
|
+
export interface FakeWebSocket {
|
|
3
|
+
readonly readyState: number;
|
|
4
|
+
send(message: string | Uint8Array | ArrayBuffer): void;
|
|
5
|
+
close(code?: number, reason?: string): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Returned by a fake's websocket handshake callback to accept a connection.
|
|
9
|
+
* Callbacks run in order per connection, including async callbacks. Capture
|
|
10
|
+
* fake state and connection-local state in the handshake callback's closure. */
|
|
11
|
+
export interface FakeWebSocketHandlers {
|
|
12
|
+
/** An offered Sec-WebSocket-Protocol to select, if the client requires one. */
|
|
13
|
+
protocol?: string;
|
|
14
|
+
open?(socket: FakeWebSocket): void | Promise<void>;
|
|
15
|
+
message?(socket: FakeWebSocket, message: string | Uint8Array): void | Promise<void>;
|
|
16
|
+
close?(socket: FakeWebSocket, code: number, reason: string): void | Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type WebSocket from "ws";
|
|
2
|
+
import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
3
|
+
|
|
4
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
5
|
+
const MAX_PENDING = 1024;
|
|
6
|
+
|
|
7
|
+
/** Serialize async fake callbacks without allowing a slow handler or peer to
|
|
8
|
+
* retain unbounded audio. A handler failure closes just this connection. */
|
|
9
|
+
export function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void {
|
|
10
|
+
let tail = Promise.resolve();
|
|
11
|
+
let pending = 0;
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
let failed = false;
|
|
14
|
+
const socket: FakeWebSocket = {
|
|
15
|
+
get readyState() { return ws.readyState; },
|
|
16
|
+
send(message) {
|
|
17
|
+
if (ws.readyState !== 1) throw new Error("WebSocket is not open");
|
|
18
|
+
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
19
|
+
if (ws.bufferedAmount + size > MAX_BUFFER) {
|
|
20
|
+
failed = true;
|
|
21
|
+
ws.close(1009, "fake output buffer exceeded");
|
|
22
|
+
throw new Error("fake WebSocket output buffer exceeded");
|
|
23
|
+
}
|
|
24
|
+
ws.send(message);
|
|
25
|
+
},
|
|
26
|
+
close: (code, reason) => ws.close(code, reason),
|
|
27
|
+
};
|
|
28
|
+
const fail = (err: unknown) => {
|
|
29
|
+
failed = true;
|
|
30
|
+
console.error("[spectest] fake WebSocket callback failed:", err);
|
|
31
|
+
try { ws.close(1011, "fake handler failed"); } catch { ws.terminate(); }
|
|
32
|
+
};
|
|
33
|
+
const enqueue = (fn: () => void | Promise<void>, size = 0, closing = false) => {
|
|
34
|
+
if (failed && !closing) return;
|
|
35
|
+
if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
|
|
36
|
+
failed = true;
|
|
37
|
+
ws.close(1009, "fake input buffer exceeded");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
pending++;
|
|
41
|
+
bytes += size;
|
|
42
|
+
tail = tail.then(async () => {
|
|
43
|
+
if (!failed || closing) await fn();
|
|
44
|
+
}).catch(fail).finally(() => { pending--; bytes -= size; });
|
|
45
|
+
};
|
|
46
|
+
ws.on("message", (data, binary) => {
|
|
47
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
|
|
48
|
+
const message = binary ? new Uint8Array(buffer) : buffer.toString();
|
|
49
|
+
enqueue(() => handlers.message?.(socket, message), buffer.length);
|
|
50
|
+
});
|
|
51
|
+
ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
|
|
52
|
+
ws.on("error", fail);
|
|
53
|
+
enqueue(() => handlers.open?.(socket));
|
|
54
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { afterEach, expect, test, spyOn } from "bun:test";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { defineFake, type FakeContext, type FakeDefinition } from "../index.js";
|
|
9
|
+
import { dispatchFake } from "./fake-websocket.js";
|
|
10
|
+
import { startIngressServer, type IngressServer } from "./ingress-server.js";
|
|
11
|
+
import { currentInstrumentationScope } from "./instrumentation-scope.js";
|
|
12
|
+
import { startTlsTerminator } from "./tls-terminator.js";
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const WS = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
16
|
+
const servers: IngressServer[] = [];
|
|
17
|
+
afterEach(() => { for (const s of servers.splice(0)) s.stop(true); });
|
|
18
|
+
|
|
19
|
+
async function serve<S>(def: FakeDefinition<S, any>, state: S) {
|
|
20
|
+
const server = await startIngressServer({ port: 0, hostname: "127.0.0.1",
|
|
21
|
+
fetch: (req, server) => dispatchFake(req, server, def, state, {} as FakeContext),
|
|
22
|
+
});
|
|
23
|
+
servers.push(server);
|
|
24
|
+
return `127.0.0.1:${server.port}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function client(url: string, protocols?: string[], options?: import("ws").ClientOptions) {
|
|
28
|
+
const ws = new WS(url.startsWith("wss://") ? url : `ws://${url}`, protocols, options);
|
|
29
|
+
const messages: { data: Buffer; binary: boolean }[] = [];
|
|
30
|
+
const waiting: ((value: { data: Buffer; binary: boolean }) => void)[] = [];
|
|
31
|
+
ws.on("message", (data, binary) => {
|
|
32
|
+
const value = { data: Buffer.from(data as Buffer), binary };
|
|
33
|
+
const next = waiting.shift();
|
|
34
|
+
if (next) next(value); else messages.push(value);
|
|
35
|
+
});
|
|
36
|
+
const open = once(ws, "open");
|
|
37
|
+
const closed = once(ws, "close");
|
|
38
|
+
return { ws, open, closed, next: () => messages.length ? Promise.resolve(messages.shift()!) :
|
|
39
|
+
new Promise<{ data: Buffer; binary: boolean }>(resolve => waiting.push(resolve)) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("HTTP and WebSocket share state, helpers, ordered async callbacks and binary frames", async () => {
|
|
43
|
+
const state = { events: [] as string[], closed: "" };
|
|
44
|
+
const def = defineFake({
|
|
45
|
+
name: "speech", hostnames: ["speech.test"],
|
|
46
|
+
state: () => state,
|
|
47
|
+
handler: (_req, state) => Response.json(state.events),
|
|
48
|
+
helpers: ({ state }) => ({ events: () => [...state.events] }),
|
|
49
|
+
websocket: async (req, state) => {
|
|
50
|
+
expect(new URL(req.url).pathname).toBe("/realtime");
|
|
51
|
+
expect(currentInstrumentationScope()).toBeNull();
|
|
52
|
+
return {
|
|
53
|
+
protocol: "audio",
|
|
54
|
+
async open(ws) {
|
|
55
|
+
await Bun.sleep(10);
|
|
56
|
+
state.events.push("open");
|
|
57
|
+
ws.send("ready");
|
|
58
|
+
},
|
|
59
|
+
async message(ws, message) {
|
|
60
|
+
await Bun.sleep(5);
|
|
61
|
+
expect(currentInstrumentationScope()).toBeNull();
|
|
62
|
+
state.events.push(typeof message === "string" ? message : "binary");
|
|
63
|
+
ws.send(message);
|
|
64
|
+
},
|
|
65
|
+
close(_ws, code, reason) { state.closed = `${code}:${reason}`; },
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const addr = await serve(def, state);
|
|
70
|
+
const c = client(`${addr}/realtime`, ["other", "audio"]);
|
|
71
|
+
await c.open;
|
|
72
|
+
expect(c.ws.protocol).toBe("audio");
|
|
73
|
+
c.ws.send("first");
|
|
74
|
+
c.ws.send(new Uint8Array([0, 128, 255]));
|
|
75
|
+
expect((await c.next()).data.toString()).toBe("ready");
|
|
76
|
+
expect((await c.next()).data.toString()).toBe("first");
|
|
77
|
+
expect(await c.next()).toEqual({ data: Buffer.from([0, 128, 255]), binary: true });
|
|
78
|
+
expect(await (await fetch(`http://${addr}/events`)).json()).toEqual(["open", "first", "binary"]);
|
|
79
|
+
const helpers = await def.helpers!({ name: "speech", state, ctx: {} as FakeContext });
|
|
80
|
+
expect(helpers.events()).toEqual(["open", "first", "binary"]);
|
|
81
|
+
c.ws.close(4001, "finished");
|
|
82
|
+
await c.closed;
|
|
83
|
+
for (let i = 0; !state.closed && i < 50; i++) await Bun.sleep(2);
|
|
84
|
+
expect(state.closed).toBe("4001:finished");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("handshake can reject before upgrade with an HTTP status and body", async () => {
|
|
88
|
+
const addr = await serve(defineFake({ name: "auth", hostnames: ["auth.test"],
|
|
89
|
+
handler: () => new Response("http"),
|
|
90
|
+
websocket: req => req.headers.get("authorization") === "Bearer valid"
|
|
91
|
+
? {} : new Response("token required", { status: 401 }),
|
|
92
|
+
}), undefined);
|
|
93
|
+
const ws = new WS(`ws://${addr}/realtime`);
|
|
94
|
+
const [req, response] = await once(ws, "unexpected-response");
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
for await (const chunk of response) chunks.push(Buffer.from(chunk));
|
|
97
|
+
expect(response.statusCode).toBe(401);
|
|
98
|
+
expect(Buffer.concat(chunks).toString()).toBe("token required");
|
|
99
|
+
req.destroy();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("an unoffered protocol fails the handshake", async () => {
|
|
103
|
+
const addr = await serve(defineFake({ name: "bad", hostnames: ["bad.test"],
|
|
104
|
+
handler: () => new Response("http"), websocket: () => ({ protocol: "unoffered" }),
|
|
105
|
+
}), undefined);
|
|
106
|
+
const ws = new WS(`ws://${addr}`, ["offered"]);
|
|
107
|
+
const [req, response] = await once(ws, "unexpected-response");
|
|
108
|
+
expect(response.statusCode).toBe(500);
|
|
109
|
+
response.resume();
|
|
110
|
+
req.destroy();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a throwing async callback closes its connection and later connections still work", async () => {
|
|
114
|
+
const errors = spyOn(console, "error").mockImplementation(() => {});
|
|
115
|
+
try {
|
|
116
|
+
const addr = await serve(defineFake({ name: "errors", hostnames: ["errors.test"],
|
|
117
|
+
handler: () => new Response("healthy"),
|
|
118
|
+
websocket: () => ({ async message(ws, message) {
|
|
119
|
+
if (message === "fail") throw new Error("intentional failure");
|
|
120
|
+
ws.send(message);
|
|
121
|
+
} }),
|
|
122
|
+
}), undefined);
|
|
123
|
+
const c = client(addr);
|
|
124
|
+
await c.open;
|
|
125
|
+
c.ws.send("fail");
|
|
126
|
+
const [code] = await c.closed;
|
|
127
|
+
expect(code).toBe(1011);
|
|
128
|
+
const next = client(addr);
|
|
129
|
+
await next.open;
|
|
130
|
+
next.ws.send("works");
|
|
131
|
+
expect((await next.next()).data.toString()).toBe("works");
|
|
132
|
+
next.ws.close();
|
|
133
|
+
await next.closed;
|
|
134
|
+
expect(errors).toHaveBeenCalled();
|
|
135
|
+
} finally { errors.mockRestore(); }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("HTTP-only fakes retain their existing handling of upgrade requests", async () => {
|
|
139
|
+
const addr = await serve(defineFake({ name: "http", hostnames: ["http.test"],
|
|
140
|
+
handler: () => new Response("no realtime", { status: 404 }),
|
|
141
|
+
}), undefined);
|
|
142
|
+
const ws = new WS(`ws://${addr}`);
|
|
143
|
+
const [req, response] = await once(ws, "unexpected-response");
|
|
144
|
+
expect(response.statusCode).toBe(404);
|
|
145
|
+
response.resume();
|
|
146
|
+
req.destroy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("WSS uses the shared TLS terminator with real certificate verification", async () => {
|
|
150
|
+
const dir = mkdtempSync(join(tmpdir(), "spectest-fake-wss-"));
|
|
151
|
+
let tls: Awaited<ReturnType<typeof startTlsTerminator>> | undefined;
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
|
154
|
+
"-subj", "/CN=speech.test", "-addext", "subjectAltName=DNS:speech.test",
|
|
155
|
+
"-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
|
|
156
|
+
const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
|
|
157
|
+
const addr = await serve(defineFake({ name: "tls", hostnames: ["speech.test"],
|
|
158
|
+
handler: () => new Response("http"),
|
|
159
|
+
websocket: req => {
|
|
160
|
+
expect(req.headers.get("host")).toBe("speech.test");
|
|
161
|
+
expect(req.headers.get("cookie")).toBe("session=valid");
|
|
162
|
+
return { open(ws) { ws.send("secure"); }, message(ws, message) { ws.send(message); } };
|
|
163
|
+
},
|
|
164
|
+
}), undefined);
|
|
165
|
+
tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: Number(addr.split(":")[1]), certFor: () => leaf });
|
|
166
|
+
const c = client(`wss://127.0.0.1:${tls.port}`, undefined, {
|
|
167
|
+
servername: "speech.test", ca: leaf.cert, headers: { host: "speech.test", cookie: "session=valid" },
|
|
168
|
+
});
|
|
169
|
+
await c.open;
|
|
170
|
+
expect((await c.next()).data.toString()).toBe("secure");
|
|
171
|
+
c.ws.send(new Uint8Array([1, 2, 3]));
|
|
172
|
+
expect(await c.next()).toEqual({ data: Buffer.from([1, 2, 3]), binary: true });
|
|
173
|
+
c.ws.close(); await c.closed;
|
|
174
|
+
} finally { tls?.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("each handshake owns separate connection-local state", async () => {
|
|
178
|
+
const addr = await serve(defineFake({ name: "sessions", hostnames: ["speech.test"],
|
|
179
|
+
handler: () => new Response("http"), websocket: () => {
|
|
180
|
+
let sequence = 0;
|
|
181
|
+
return { message(ws) { ws.send(String(++sequence)); } };
|
|
182
|
+
},
|
|
183
|
+
}), undefined);
|
|
184
|
+
const a = client(addr), b = client(addr);
|
|
185
|
+
await Promise.all([a.open, b.open]);
|
|
186
|
+
a.ws.send("next"); a.ws.send("next"); b.ws.send("next");
|
|
187
|
+
expect((await a.next()).data.toString()).toBe("1");
|
|
188
|
+
expect((await a.next()).data.toString()).toBe("2");
|
|
189
|
+
expect((await b.next()).data.toString()).toBe("1");
|
|
190
|
+
a.ws.close(); b.ws.close(); await Promise.all([a.closed, b.closed]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("oversized fake output closes with 1009 instead of buffering it", async () => {
|
|
194
|
+
const errors = spyOn(console, "error").mockImplementation(() => {});
|
|
195
|
+
try {
|
|
196
|
+
const addr = await serve(defineFake({ name: "bounded", hostnames: ["bounded.test"],
|
|
197
|
+
handler: () => new Response("http"),
|
|
198
|
+
websocket: () => ({ message(ws) { ws.send(new Uint8Array(16 * 1024 * 1024 + 1)); } }),
|
|
199
|
+
}), undefined);
|
|
200
|
+
const c = client(addr);
|
|
201
|
+
await c.open;
|
|
202
|
+
c.ws.send("start");
|
|
203
|
+
expect((await c.closed)[0]).toBe(1009);
|
|
204
|
+
} finally { errors.mockRestore(); }
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a slow callback cannot accumulate unlimited pending messages", async () => {
|
|
208
|
+
let release!: () => void;
|
|
209
|
+
const blocked = new Promise<void>(resolve => { release = resolve; });
|
|
210
|
+
try {
|
|
211
|
+
const addr = await serve(defineFake({ name: "slow", hostnames: ["slow.test"],
|
|
212
|
+
handler: () => new Response("http"), websocket: () => ({ open: () => blocked }),
|
|
213
|
+
}), undefined);
|
|
214
|
+
const c = client(addr);
|
|
215
|
+
await c.open;
|
|
216
|
+
for (let i = 0; i < 1100; i++) c.ws.send("queued");
|
|
217
|
+
expect((await c.closed)[0]).toBe(1009);
|
|
218
|
+
} finally { release(); }
|
|
219
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
2
|
+
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
3
|
+
import type { IngressServer } from "./ingress-server.js";
|
|
4
|
+
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
5
|
+
import { parseSubprotocols } from "./ws-bridge.js";
|
|
6
|
+
|
|
7
|
+
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
8
|
+
export async function dispatchFake<S>(
|
|
9
|
+
req: Request,
|
|
10
|
+
server: IngressServer,
|
|
11
|
+
def: FakeDefinition<S, any>,
|
|
12
|
+
state: S,
|
|
13
|
+
ctx: FakeContext,
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
try {
|
|
16
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
|
|
17
|
+
return await runUninstrumented(() => def.handler(req, state, ctx));
|
|
18
|
+
}
|
|
19
|
+
const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
|
|
20
|
+
if (accepted instanceof Response) return accepted;
|
|
21
|
+
if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
|
|
22
|
+
if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
|
|
23
|
+
throw new Error("websocket selected a protocol the client did not offer");
|
|
24
|
+
}
|
|
25
|
+
const handlers: FakeWebSocketHandlers = {
|
|
26
|
+
open: socket => runUninstrumented(() => accepted.open?.(socket)),
|
|
27
|
+
message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
|
|
28
|
+
close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
|
|
29
|
+
};
|
|
30
|
+
return server.upgrade(req, {
|
|
31
|
+
handlers,
|
|
32
|
+
headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
|
|
33
|
+
}) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
36
|
+
status: 500, headers: { "content-type": "text/plain" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|