@specific.dev/spectest 0.103.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/dist/browser.d.ts CHANGED
@@ -22,6 +22,13 @@ export interface BrowserOptions {
22
22
  frame?: "browser" | "mobile";
23
23
  /** Initial URL to navigate to before the constructor returns. */
24
24
  url?: string;
25
+ /** IANA timezone, e.g. "Europe/Stockholm". Defaults to the host timezone.
26
+ * Set on first creation; inherited sessions keep their original timezone. */
27
+ timezone?: string;
28
+ /** Locale, e.g. "sv-SE", for navigator.language, Accept-Language and
29
+ * date/number formatting. Omit to use the browser's default locale.
30
+ * Set on first creation; inherited sessions keep their original locale. */
31
+ locale?: string;
25
32
  /** Feed a WAV fixture through Chromium's native microphone capture.
26
33
  * Set on the session's first creation; inherited sessions keep their source.
27
34
  * Fixture sessions use a dedicated Chromium process for audio isolation. */
@@ -296,7 +303,7 @@ export interface SafeAreaInsets {
296
303
  * Pre-open `n` views into the pool (called by the daemon at the end of
297
304
  * /bootstrap, before the warm-template snapshot is captured). Only
298
305
  * default-viewport views are pooled — `openBrowser` with a custom
299
- * width/height bypasses the pool.
306
+ * width/height, timezone, locale or microphone bypasses the pool.
300
307
  */
301
308
  export declare function prewarmViewPool(n?: number): Promise<void>;
302
309
  /**
package/dist/browser.js CHANGED
@@ -160,20 +160,24 @@ function deviceContextOptions(d) {
160
160
  }
161
161
  /** Create the context one view lives in — device emulation when the view is
162
162
  * phone-emulated, a plain viewport otherwise. */
163
- async function newViewContext(width, height, device, microphone) {
164
- const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
163
+ async function newViewContext(width, height, device, opts = {}) {
164
+ const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
165
165
  let browser;
166
166
  try {
167
167
  browser = fixture ? await chromium.launch({
168
168
  executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
169
169
  }) : await ensurePlaywrightBrowser();
170
- const context = await browser.newContext(device
171
- ? deviceContextOptions(device)
172
- : {
173
- viewport: { width, height },
174
- // Reduced-motion for replay fidelity — see deviceContextOptions.
175
- reducedMotion: "reduce",
176
- });
170
+ const context = await browser.newContext({
171
+ ...(device
172
+ ? deviceContextOptions(device)
173
+ : {
174
+ viewport: { width, height },
175
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
176
+ reducedMotion: "reduce",
177
+ }),
178
+ timezoneId: opts.timezone,
179
+ locale: opts.locale,
180
+ });
177
181
  context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
178
182
  context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
179
183
  if (fixture) {
@@ -879,7 +883,7 @@ async function createView(width, height) {
879
883
  * Pre-open `n` views into the pool (called by the daemon at the end of
880
884
  * /bootstrap, before the warm-template snapshot is captured). Only
881
885
  * default-viewport views are pooled — `openBrowser` with a custom
882
- * width/height bypasses the pool.
886
+ * width/height, timezone, locale or microphone bypasses the pool.
883
887
  */
884
888
  export async function prewarmViewPool(n = 1) {
885
889
  for (let i = 0; i < n; i++) {
@@ -910,10 +914,11 @@ export async function openMobileBackend(opts = {}) {
910
914
  const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
911
915
  const wantW = device ? device.viewport.width : opts.width ?? 1280;
912
916
  const wantH = device ? device.viewport.height : opts.height ?? 720;
913
- // Mobile views are never pooled — the pool holds only default-desktop
914
- // views (in the shared desktop context), and a mobile view needs its own
915
- // emulated context anyway.
916
- const pooled = !device && opts.microphone === undefined && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
917
+ // The pool holds only default-desktop contexts. Context creation options
918
+ // such as timezone and locale require a fresh context.
919
+ const pooled = !device && opts.microphone === undefined && opts.timezone === undefined &&
920
+ opts.locale === undefined && wantW === 1280 && wantH === 720
921
+ ? VIEW_POOL.pop() : undefined;
917
922
  let holder;
918
923
  if (pooled) {
919
924
  holder = {
@@ -930,7 +935,7 @@ export async function openMobileBackend(opts = {}) {
930
935
  };
931
936
  }
932
937
  else {
933
- holder = await newHolder(wantW, wantH, device, opts.microphone);
938
+ holder = await newHolder(wantW, wantH, device, opts);
934
939
  }
935
940
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
936
941
  persistent: false,
@@ -992,8 +997,8 @@ function checkSessionCap(kind, live, name) {
992
997
  `Each named session is a live browser captured in every snapshot from here on — name them for the ` +
993
998
  `roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
994
999
  }
995
- async function newHolder(width, height, device, microphone) {
996
- const context = await newViewContext(width, height, device, microphone);
1000
+ async function newHolder(width, height, device, opts = {}) {
1001
+ const context = await newViewContext(width, height, device, opts);
997
1002
  let spawned;
998
1003
  try {
999
1004
  spawned = await spawnPage(context);
@@ -1073,7 +1078,7 @@ export async function acquirePersistentBrowser(name = "", opts = {}) {
1073
1078
  }
1074
1079
  else {
1075
1080
  checkSessionCap("browser", SHARED_BROWSERS.size, name);
1076
- holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts.microphone);
1081
+ holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts);
1077
1082
  SHARED_BROWSERS.set(name, holder);
1078
1083
  }
1079
1084
  const view = holder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.103.0",
3
+ "version": "0.104.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
@@ -93,6 +93,13 @@ export interface BrowserOptions {
93
93
  frame?: "browser" | "mobile";
94
94
  /** Initial URL to navigate to before the constructor returns. */
95
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;
96
103
  /** Feed a WAV fixture through Chromium's native microphone capture.
97
104
  * Set on the session's first creation; inherited sessions keep their source.
98
105
  * Fixture sessions use a dedicated Chromium process for audio isolation. */
@@ -510,23 +517,25 @@ async function newViewContext(
510
517
  width: number,
511
518
  height: number,
512
519
  device: DevicePreset | null,
513
- microphone?: MicrophoneFixture,
520
+ opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
514
521
  ): Promise<BrowserContext> {
515
- const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
522
+ const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
516
523
  let browser: PlaywrightBrowser | undefined;
517
524
  try {
518
525
  browser = fixture ? await chromium.launch({
519
526
  executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
520
527
  }) : await ensurePlaywrightBrowser();
521
- const context = await browser.newContext(
522
- device
528
+ const context = await browser.newContext({
529
+ ...(device
523
530
  ? deviceContextOptions(device)
524
531
  : {
525
532
  viewport: { width, height },
526
533
  // Reduced-motion for replay fidelity — see deviceContextOptions.
527
534
  reducedMotion: "reduce",
528
- },
529
- );
535
+ }),
536
+ timezoneId: opts.timezone,
537
+ locale: opts.locale,
538
+ });
530
539
  context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
531
540
  context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
532
541
  if (fixture) {
@@ -1310,7 +1319,7 @@ async function createView(width: number, height: number): Promise<PooledView> {
1310
1319
  * Pre-open `n` views into the pool (called by the daemon at the end of
1311
1320
  * /bootstrap, before the warm-template snapshot is captured). Only
1312
1321
  * default-viewport views are pooled — `openBrowser` with a custom
1313
- * width/height bypasses the pool.
1322
+ * width/height, timezone, locale or microphone bypasses the pool.
1314
1323
  */
1315
1324
  export async function prewarmViewPool(n = 1): Promise<void> {
1316
1325
  for (let i = 0; i < n; i++) {
@@ -1345,11 +1354,12 @@ export async function openMobileBackend(
1345
1354
  const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
1346
1355
  const wantW = device ? device.viewport.width : opts.width ?? 1280;
1347
1356
  const wantH = device ? device.viewport.height : opts.height ?? 720;
1348
- // Mobile views are never pooled — the pool holds only default-desktop
1349
- // views (in the shared desktop context), and a mobile view needs its own
1350
- // emulated context anyway.
1357
+ // The pool holds only default-desktop contexts. Context creation options
1358
+ // such as timezone and locale require a fresh context.
1351
1359
  const pooled =
1352
- !device && opts.microphone === undefined && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
1360
+ !device && opts.microphone === undefined && opts.timezone === undefined &&
1361
+ opts.locale === undefined && wantW === 1280 && wantH === 720
1362
+ ? VIEW_POOL.pop() : undefined;
1353
1363
  let holder: ViewHolder;
1354
1364
  if (pooled) {
1355
1365
  holder = {
@@ -1365,7 +1375,7 @@ export async function openMobileBackend(
1365
1375
  initScripts: [],
1366
1376
  };
1367
1377
  } else {
1368
- holder = await newHolder(wantW, wantH, device, opts.microphone);
1378
+ holder = await newHolder(wantW, wantH, device, opts);
1369
1379
  }
1370
1380
 
1371
1381
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
@@ -1437,9 +1447,9 @@ async function newHolder(
1437
1447
  width: number,
1438
1448
  height: number,
1439
1449
  device: DevicePreset | null,
1440
- microphone?: MicrophoneFixture,
1450
+ opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
1441
1451
  ): Promise<ViewHolder> {
1442
- const context = await newViewContext(width, height, device, microphone);
1452
+ const context = await newViewContext(width, height, device, opts);
1443
1453
  let spawned: SpawnedPage;
1444
1454
  try { spawned = await spawnPage(context); } catch (err) {
1445
1455
  await closeViewContext(context);
@@ -1539,7 +1549,7 @@ export async function acquirePersistentBrowser(
1539
1549
  device ? device.viewport.width : opts.width ?? 1280,
1540
1550
  device ? device.viewport.height : opts.height ?? 720,
1541
1551
  device,
1542
- opts.microphone,
1552
+ opts,
1543
1553
  );
1544
1554
  SHARED_BROWSERS.set(name, holder);
1545
1555
  }