@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 +8 -1
- package/dist/browser.js +23 -18
- package/package.json +1 -1
- package/src/browser-timezone.test.ts +105 -0
- package/src/browser.ts +25 -15
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,
|
|
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(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
//
|
|
914
|
-
//
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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,
|
|
996
|
-
const context = await newViewContext(width, height, device,
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
//
|
|
1349
|
-
//
|
|
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 &&
|
|
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
|
|
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
|
|
1450
|
+
opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
|
|
1441
1451
|
): Promise<ViewHolder> {
|
|
1442
|
-
const context = await newViewContext(width, height, device,
|
|
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
|
|
1552
|
+
opts,
|
|
1543
1553
|
);
|
|
1544
1554
|
SHARED_BROWSERS.set(name, holder);
|
|
1545
1555
|
}
|