@volter/world-core 3.0.36 → 3.0.38

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.
@@ -1,4 +1,4 @@
1
- /** The frontend state of a scenario, in Playwright's storageState form. */
1
+ /** A scenario's browser profile, using Playwright's storageState and context option shapes. */
2
2
  export type BrowserCookie = {
3
3
  name: string;
4
4
  value: string;
@@ -34,7 +34,7 @@ export type BrowserIndexedDB = {
34
34
  }[];
35
35
  }[];
36
36
  };
37
- export type BrowserState = {
37
+ export type BrowserStorageState = {
38
38
  cookies: BrowserCookie[];
39
39
  origins: {
40
40
  origin: string;
@@ -45,8 +45,40 @@ export type BrowserState = {
45
45
  indexedDB?: BrowserIndexedDB[];
46
46
  }[];
47
47
  };
48
+ /** Profile subset of Playwright 1.61.1 BrowserContextOptions; no execution options. */
49
+ export type BrowserContext = {
50
+ permissions?: string[];
51
+ geolocation?: {
52
+ latitude: number;
53
+ longitude: number;
54
+ accuracy?: number;
55
+ };
56
+ locale?: string;
57
+ timezoneId?: string;
58
+ userAgent?: string;
59
+ viewport?: {
60
+ width: number;
61
+ height: number;
62
+ } | null;
63
+ deviceScaleFactor?: number;
64
+ isMobile?: boolean;
65
+ hasTouch?: boolean;
66
+ colorScheme?: 'light' | 'dark' | 'no-preference' | null;
67
+ reducedMotion?: 'reduce' | 'no-preference' | null;
68
+ extraHTTPHeaders?: Record<string, string>;
69
+ offline?: boolean;
70
+ };
71
+ export type BrowserState = {
72
+ storageState: BrowserStorageState;
73
+ context: BrowserContext;
74
+ };
48
75
  export declare function assertBrowserName(name: string): void;
49
76
  export declare function browserOrigin(value: string): string;
50
77
  /** Refuse unknown keys at every level; the accepted object is never reshaped. */
78
+ export declare function validateBrowserStorageState(value: unknown): BrowserStorageState;
79
+ export declare function validateBrowserContext(value: unknown): BrowserContext;
80
+ /** Normalize legacy storage-only records and profiles without context on read. */
51
81
  export declare function validateBrowserState(value: unknown): BrowserState;
82
+ /** The profile's language header, with Playwright's locale supplying its default. */
83
+ export declare function browserAcceptLanguage(context: BrowserContext): string | undefined;
52
84
  export declare function cookieMatchesHost(cookie: BrowserCookie, hostname: string): boolean;
@@ -21,7 +21,7 @@ export function browserOrigin(value) {
21
21
  return value;
22
22
  }
23
23
  /** Refuse unknown keys at every level; the accepted object is never reshaped. */
24
- export function validateBrowserState(value) {
24
+ export function validateBrowserStorageState(value) {
25
25
  const state = object(value, ['cookies', 'origins']);
26
26
  if (!Array.isArray(state.cookies) || !Array.isArray(state.origins))
27
27
  throw new Error('browser state: cookies and origins are arrays');
@@ -99,6 +99,68 @@ export function validateBrowserState(value) {
99
99
  }
100
100
  return value;
101
101
  }
102
+ export function validateBrowserContext(value) {
103
+ const context = object(value, ['permissions', 'geolocation', 'locale', 'timezoneId', 'userAgent', 'viewport',
104
+ 'deviceScaleFactor', 'isMobile', 'hasTouch', 'colorScheme', 'reducedMotion', 'extraHTTPHeaders', 'offline']);
105
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
106
+ if (context.permissions !== undefined && (!Array.isArray(context.permissions) || context.permissions.some(p => typeof p !== 'string')))
107
+ throw new Error('browser context: permissions is an array of strings');
108
+ for (const key of ['locale', 'timezoneId', 'userAgent']) {
109
+ if (context[key] !== undefined && typeof context[key] !== 'string')
110
+ throw new Error(`browser context: ${key} is a string`);
111
+ }
112
+ if (context.locale !== undefined)
113
+ new Headers({ 'accept-language': context.locale });
114
+ if (context.userAgent !== undefined)
115
+ new Headers({ 'user-agent': context.userAgent });
116
+ for (const key of ['isMobile', 'hasTouch', 'offline']) {
117
+ if (context[key] !== undefined && typeof context[key] !== 'boolean')
118
+ throw new Error(`browser context: ${key} is a boolean`);
119
+ }
120
+ if (context.deviceScaleFactor !== undefined && !finite(context.deviceScaleFactor))
121
+ throw new Error('browser context: deviceScaleFactor is a finite number');
122
+ if (context.geolocation !== undefined) {
123
+ const geo = object(context.geolocation, ['latitude', 'longitude', 'accuracy']);
124
+ if (!finite(geo.latitude) || geo.latitude < -90 || geo.latitude > 90 || !finite(geo.longitude) || geo.longitude < -180 || geo.longitude > 180
125
+ || (geo.accuracy !== undefined && (!finite(geo.accuracy) || geo.accuracy < 0)))
126
+ throw new Error('browser context: geolocation has latitude [-90, 90], longitude [-180, 180] and optional nonnegative accuracy');
127
+ }
128
+ if (context.viewport !== undefined && context.viewport !== null) {
129
+ const viewport = object(context.viewport, ['width', 'height']);
130
+ if (!finite(viewport.width) || !Number.isInteger(viewport.width) || !finite(viewport.height) || !Number.isInteger(viewport.height))
131
+ throw new Error('browser context: viewport has integer width and height, or is null');
132
+ }
133
+ if (context.viewport === null && (context.deviceScaleFactor !== undefined || context.isMobile === true))
134
+ throw new Error('browser context: null viewport does not support deviceScaleFactor or isMobile');
135
+ for (const [key, values] of [['colorScheme', ['light', 'dark', 'no-preference']], ['reducedMotion', ['reduce', 'no-preference']]]) {
136
+ if (context[key] !== undefined && context[key] !== null && (typeof context[key] !== 'string' || !values.includes(context[key])))
137
+ throw new Error(`browser context: ${key} is ${values.join(', ')} or null`);
138
+ }
139
+ if (context.extraHTTPHeaders !== undefined) {
140
+ const headers = context.extraHTTPHeaders;
141
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers) || Object.values(headers).some(v => typeof v !== 'string'))
142
+ throw new Error('browser context: extraHTTPHeaders maps header names to strings');
143
+ // Validate wire values without normalizing the profile's names or values.
144
+ new Headers(headers);
145
+ }
146
+ return value;
147
+ }
148
+ /** Normalize legacy storage-only records and profiles without context on read. */
149
+ export function validateBrowserState(value) {
150
+ if (value && typeof value === 'object' && ('cookies' in value || 'origins' in value)) {
151
+ return { storageState: validateBrowserStorageState(value), context: {} };
152
+ }
153
+ const profile = object(value, ['storageState', 'context']);
154
+ return { storageState: validateBrowserStorageState(profile.storageState), context: validateBrowserContext(profile.context === undefined ? {} : profile.context) };
155
+ }
156
+ /** The profile's language header, with Playwright's locale supplying its default. */
157
+ export function browserAcceptLanguage(context) {
158
+ for (const [name, value] of Object.entries(context.extraHTTPHeaders ?? {})) {
159
+ if (name.toLowerCase() === 'accept-language')
160
+ return value;
161
+ }
162
+ return context.locale;
163
+ }
102
164
  export function cookieMatchesHost(cookie, hostname) {
103
165
  const domain = cookie.domain.replace(/^\./, '');
104
166
  return hostname === domain || (cookie.domain.startsWith('.') && hostname.endsWith(`.${domain}`));
@@ -1,4 +1,4 @@
1
- import { type BrowserState } from './browser-state.js';
1
+ import { type BrowserState, type BrowserContext } from './browser-state.js';
2
2
  export { cookieMatchesHost } from './browser-state.js';
3
3
  export type SeedBrowser = {
4
4
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
@@ -6,6 +6,7 @@ export type SeedBrowser = {
6
6
  set(key: string, value: string): Promise<void>;
7
7
  remove(key: string): Promise<void>;
8
8
  };
9
+ context(options: BrowserContext): Promise<void>;
9
10
  state(): Promise<BrowserState>;
10
11
  };
11
12
  /** A seed's named browser. Every state write goes through the World's single HTTP loader. */
@@ -1,5 +1,5 @@
1
1
  import { BrowserCookieJar } from "./browser-cookie-jar.js";
2
- import { assertBrowserName, browserOrigin, validateBrowserState } from "./browser-state.js";
2
+ import { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserContext, browserAcceptLanguage } from "./browser-state.js";
3
3
  import { nodeBuiltin } from "./serve-http.js";
4
4
  import { worldNow } from "./world-clock.js";
5
5
  export { cookieMatchesHost } from "./browser-state.js";
@@ -97,15 +97,16 @@ export async function browser(name) {
97
97
  const initial = await fetch(`${door}/${name}`, { headers: { 'x-volter-token': token } });
98
98
  if (!initial.ok && initial.status !== 404)
99
99
  throw new Error(`browser ${name}: ${initial.status} ${await initial.text()}`);
100
- const stored = initial.ok ? validateBrowserState(await initial.json()) : { cookies: [], origins: [] };
101
- const jar = new BrowserCookieJar(stored.cookies);
102
- const origins = structuredClone(stored.origins);
100
+ const stored = validateBrowserState(initial.ok ? await initial.json() : { cookies: [], origins: [] });
101
+ const jar = new BrowserCookieJar(stored.storageState.cookies);
102
+ const origins = structuredClone(stored.storageState.origins);
103
+ let context = structuredClone(stored.context);
103
104
  let queue = Promise.resolve();
104
105
  const serial = (fn) => { const next = queue.then(fn); queue = next.then(() => undefined, () => undefined); return next; };
105
106
  const snapshot = () => {
106
107
  const now = new Date(worldNow()).getTime();
107
108
  const cookies = jar.snapshot(now);
108
- return validateBrowserState({ cookies, origins: structuredClone(origins) });
109
+ return validateBrowserState({ storageState: { cookies, origins: structuredClone(origins) }, context: structuredClone(context) });
109
110
  };
110
111
  const save = async () => {
111
112
  const response = await fetch(`${door}/${name}`, { method: 'PUT', headers: { 'content-type': 'application/json', 'x-volter-token': token }, body: JSON.stringify(snapshot()) });
@@ -116,6 +117,20 @@ export async function browser(name) {
116
117
  await save();
117
118
  return {
118
119
  state: () => serial(async () => snapshot()),
120
+ context: options => serial(async () => {
121
+ const next = structuredClone(validateBrowserContext(options));
122
+ if (JSON.stringify(next) === JSON.stringify(context))
123
+ return;
124
+ const previous = context;
125
+ context = next;
126
+ try {
127
+ await save();
128
+ }
129
+ catch (error) {
130
+ context = previous;
131
+ throw error;
132
+ }
133
+ }),
119
134
  storage(origin) {
120
135
  browserOrigin(origin);
121
136
  const change = (key, value) => serial(async () => {
@@ -156,6 +171,9 @@ export async function browser(name) {
156
171
  at.pathname = `${mount}${url.pathname}`;
157
172
  at.search = url.search;
158
173
  const sent = new Headers(headers);
174
+ const language = browserAcceptLanguage(context);
175
+ if (language !== undefined)
176
+ sent.set('accept-language', language);
159
177
  const cookies = jar.header(url, new Date(worldNow()).getTime());
160
178
  if (cookies)
161
179
  sent.set('cookie', cookies);
@@ -65,4 +65,4 @@ export * as git from './git/index.js';
65
65
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.js';
66
66
  export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.js';
67
67
  export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.js';
68
- export { assertBrowserName, browserOrigin, validateBrowserState, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserIndexedDB } from './browser-state.js';
68
+ export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.js';
package/dist/src/index.js CHANGED
@@ -60,4 +60,4 @@ export * as git from "./git/index.js";
60
60
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
61
61
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from "./scenario.js";
62
62
  export { browserSessionToken, startBrowserSession, endBrowserSession } from "./browser-session.js";
63
- export { assertBrowserName, browserOrigin, validateBrowserState, cookieMatchesHost } from "./browser-state.js";
63
+ export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost } from "./browser-state.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.36",
3
+ "version": "3.0.38",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -1,11 +1,28 @@
1
- /** The frontend state of a scenario, in Playwright's storageState form. */
1
+ /** A scenario's browser profile, using Playwright's storageState and context option shapes. */
2
2
  export type BrowserCookie = { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None'; partitionKey?: string; _crHasCrossSiteAncestor?: boolean };
3
3
  export type BrowserIndexedDB = { name: string; version: number; stores: {
4
4
  name: string; autoIncrement: boolean; keyPath?: string; keyPathArray?: string[];
5
5
  records: { key?: unknown; keyEncoded?: unknown; value?: unknown; valueEncoded?: unknown }[];
6
6
  indexes: { name: string; keyPath?: string; keyPathArray?: string[]; multiEntry: boolean; unique: boolean }[];
7
7
  }[] };
8
- export type BrowserState = { cookies: BrowserCookie[]; origins: { origin: string; localStorage: { name: string; value: string }[]; indexedDB?: BrowserIndexedDB[] }[] };
8
+ export type BrowserStorageState = { cookies: BrowserCookie[]; origins: { origin: string; localStorage: { name: string; value: string }[]; indexedDB?: BrowserIndexedDB[] }[] };
9
+ /** Profile subset of Playwright 1.61.1 BrowserContextOptions; no execution options. */
10
+ export type BrowserContext = {
11
+ permissions?: string[];
12
+ geolocation?: { latitude: number; longitude: number; accuracy?: number };
13
+ locale?: string;
14
+ timezoneId?: string;
15
+ userAgent?: string;
16
+ viewport?: { width: number; height: number } | null;
17
+ deviceScaleFactor?: number;
18
+ isMobile?: boolean;
19
+ hasTouch?: boolean;
20
+ colorScheme?: 'light' | 'dark' | 'no-preference' | null;
21
+ reducedMotion?: 'reduce' | 'no-preference' | null;
22
+ extraHTTPHeaders?: Record<string, string>;
23
+ offline?: boolean;
24
+ };
25
+ export type BrowserState = { storageState: BrowserStorageState; context: BrowserContext };
9
26
 
10
27
  export function assertBrowserName(name: string): void {
11
28
  if (!/^[a-z0-9](?:[a-z0-9]|-(?!-)){0,23}$/.test(name) || name.endsWith('-')) throw new Error('browser name: 1–24 lowercase letters, digits or single hyphens, starting and ending with a letter or digit');
@@ -25,7 +42,7 @@ export function browserOrigin(value: string): string {
25
42
  }
26
43
 
27
44
  /** Refuse unknown keys at every level; the accepted object is never reshaped. */
28
- export function validateBrowserState(value: unknown): BrowserState {
45
+ export function validateBrowserStorageState(value: unknown): BrowserStorageState {
29
46
  const state = object(value, ['cookies', 'origins']);
30
47
  if (!Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error('browser state: cookies and origins are arrays');
31
48
  const cookies = new Set<string>();
@@ -80,7 +97,60 @@ export function validateBrowserState(value: unknown): BrowserState {
80
97
  names.add(entry.name);
81
98
  }
82
99
  }
83
- return value as BrowserState;
100
+ return value as BrowserStorageState;
101
+ }
102
+
103
+ export function validateBrowserContext(value: unknown): BrowserContext {
104
+ const context = object(value, ['permissions', 'geolocation', 'locale', 'timezoneId', 'userAgent', 'viewport',
105
+ 'deviceScaleFactor', 'isMobile', 'hasTouch', 'colorScheme', 'reducedMotion', 'extraHTTPHeaders', 'offline']);
106
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
107
+ if (context.permissions !== undefined && (!Array.isArray(context.permissions) || context.permissions.some(p => typeof p !== 'string'))) throw new Error('browser context: permissions is an array of strings');
108
+ for (const key of ['locale', 'timezoneId', 'userAgent']) {
109
+ if (context[key] !== undefined && typeof context[key] !== 'string') throw new Error(`browser context: ${key} is a string`);
110
+ }
111
+ if (context.locale !== undefined) new Headers({ 'accept-language': context.locale as string });
112
+ if (context.userAgent !== undefined) new Headers({ 'user-agent': context.userAgent as string });
113
+ for (const key of ['isMobile', 'hasTouch', 'offline']) {
114
+ if (context[key] !== undefined && typeof context[key] !== 'boolean') throw new Error(`browser context: ${key} is a boolean`);
115
+ }
116
+ if (context.deviceScaleFactor !== undefined && !finite(context.deviceScaleFactor)) throw new Error('browser context: deviceScaleFactor is a finite number');
117
+ if (context.geolocation !== undefined) {
118
+ const geo = object(context.geolocation, ['latitude', 'longitude', 'accuracy']);
119
+ if (!finite(geo.latitude) || geo.latitude < -90 || geo.latitude > 90 || !finite(geo.longitude) || geo.longitude < -180 || geo.longitude > 180
120
+ || (geo.accuracy !== undefined && (!finite(geo.accuracy) || geo.accuracy < 0))) throw new Error('browser context: geolocation has latitude [-90, 90], longitude [-180, 180] and optional nonnegative accuracy');
121
+ }
122
+ if (context.viewport !== undefined && context.viewport !== null) {
123
+ const viewport = object(context.viewport, ['width', 'height']);
124
+ if (!finite(viewport.width) || !Number.isInteger(viewport.width) || !finite(viewport.height) || !Number.isInteger(viewport.height)) throw new Error('browser context: viewport has integer width and height, or is null');
125
+ }
126
+ if (context.viewport === null && (context.deviceScaleFactor !== undefined || context.isMobile === true)) throw new Error('browser context: null viewport does not support deviceScaleFactor or isMobile');
127
+ for (const [key, values] of [['colorScheme', ['light', 'dark', 'no-preference']], ['reducedMotion', ['reduce', 'no-preference']]] as const) {
128
+ if (context[key] !== undefined && context[key] !== null && (typeof context[key] !== 'string' || !(values as readonly string[]).includes(context[key]))) throw new Error(`browser context: ${key} is ${values.join(', ')} or null`);
129
+ }
130
+ if (context.extraHTTPHeaders !== undefined) {
131
+ const headers = context.extraHTTPHeaders;
132
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers) || Object.values(headers).some(v => typeof v !== 'string')) throw new Error('browser context: extraHTTPHeaders maps header names to strings');
133
+ // Validate wire values without normalizing the profile's names or values.
134
+ new Headers(headers as Record<string, string>);
135
+ }
136
+ return value as BrowserContext;
137
+ }
138
+
139
+ /** Normalize legacy storage-only records and profiles without context on read. */
140
+ export function validateBrowserState(value: unknown): BrowserState {
141
+ if (value && typeof value === 'object' && ('cookies' in value || 'origins' in value)) {
142
+ return { storageState: validateBrowserStorageState(value), context: {} };
143
+ }
144
+ const profile = object(value, ['storageState', 'context']);
145
+ return { storageState: validateBrowserStorageState(profile.storageState), context: validateBrowserContext(profile.context === undefined ? {} : profile.context) };
146
+ }
147
+
148
+ /** The profile's language header, with Playwright's locale supplying its default. */
149
+ export function browserAcceptLanguage(context: BrowserContext): string | undefined {
150
+ for (const [name, value] of Object.entries(context.extraHTTPHeaders ?? {})) {
151
+ if (name.toLowerCase() === 'accept-language') return value;
152
+ }
153
+ return context.locale;
84
154
  }
85
155
 
86
156
  export function cookieMatchesHost(cookie: BrowserCookie, hostname: string): boolean {
package/src/browser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BrowserCookieJar } from './browser-cookie-jar.ts';
2
- import { assertBrowserName, browserOrigin, validateBrowserState, type BrowserState } from './browser-state.ts';
2
+ import { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserContext, browserAcceptLanguage, type BrowserState, type BrowserContext } from './browser-state.ts';
3
3
  import { nodeBuiltin } from './serve-http.ts';
4
4
  import { worldNow } from './world-clock.ts';
5
5
 
@@ -8,6 +8,7 @@ export { cookieMatchesHost } from './browser-state.ts';
8
8
  export type SeedBrowser = {
9
9
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
10
10
  storage(origin: string): { set(key: string, value: string): Promise<void>; remove(key: string): Promise<void> };
11
+ context(options: BrowserContext): Promise<void>;
11
12
  state(): Promise<BrowserState>;
12
13
  };
13
14
 
@@ -87,15 +88,16 @@ export async function browser(name: string): Promise<SeedBrowser> {
87
88
  };
88
89
  const initial = await fetch(`${door}/${name}`, { headers: { 'x-volter-token': token } });
89
90
  if (!initial.ok && initial.status !== 404) throw new Error(`browser ${name}: ${initial.status} ${await initial.text()}`);
90
- const stored = initial.ok ? validateBrowserState(await initial.json()) : { cookies: [], origins: [] };
91
- const jar = new BrowserCookieJar(stored.cookies);
92
- const origins = structuredClone(stored.origins);
91
+ const stored = validateBrowserState(initial.ok ? await initial.json() : { cookies: [], origins: [] });
92
+ const jar = new BrowserCookieJar(stored.storageState.cookies);
93
+ const origins = structuredClone(stored.storageState.origins);
94
+ let context = structuredClone(stored.context);
93
95
  let queue: Promise<unknown> = Promise.resolve();
94
96
  const serial = <T>(fn: () => Promise<T>): Promise<T> => { const next = queue.then(fn); queue = next.then(() => undefined, () => undefined); return next; };
95
97
  const snapshot = (): BrowserState => {
96
98
  const now = new Date(worldNow()).getTime();
97
99
  const cookies = jar.snapshot(now);
98
- return validateBrowserState({ cookies, origins: structuredClone(origins) });
100
+ return validateBrowserState({ storageState: { cookies, origins: structuredClone(origins) }, context: structuredClone(context) });
99
101
  };
100
102
  const save = async (): Promise<void> => {
101
103
  const response = await fetch(`${door}/${name}`, { method: 'PUT', headers: { 'content-type': 'application/json', 'x-volter-token': token }, body: JSON.stringify(snapshot()) });
@@ -104,6 +106,13 @@ export async function browser(name: string): Promise<SeedBrowser> {
104
106
  if (initial.status === 404) await save();
105
107
  return {
106
108
  state: () => serial(async () => snapshot()),
109
+ context: options => serial(async () => {
110
+ const next = structuredClone(validateBrowserContext(options));
111
+ if (JSON.stringify(next) === JSON.stringify(context)) return;
112
+ const previous = context;
113
+ context = next;
114
+ try { await save(); } catch (error) { context = previous; throw error; }
115
+ }),
107
116
  storage(origin) {
108
117
  browserOrigin(origin);
109
118
  const change = (key: string, value?: string): Promise<void> => serial(async () => {
@@ -135,6 +144,8 @@ export async function browser(name: string): Promise<SeedBrowser> {
135
144
  at.pathname = `${mount}${url.pathname}`;
136
145
  at.search = url.search;
137
146
  const sent = new Headers(headers);
147
+ const language = browserAcceptLanguage(context);
148
+ if (language !== undefined) sent.set('accept-language', language);
138
149
  const cookies = jar.header(url, new Date(worldNow()).getTime());
139
150
  if (cookies) sent.set('cookie', cookies);
140
151
  sent.set('host', mount ? at.host : url.host);
package/src/index.ts CHANGED
@@ -92,4 +92,4 @@ export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioE
92
92
 
93
93
  export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
94
94
 
95
- export { assertBrowserName, browserOrigin, validateBrowserState, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserIndexedDB } from './browser-state.ts';
95
+ export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.ts';