@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.
- package/dist/src/browser-state.d.ts +34 -2
- package/dist/src/browser-state.js +63 -1
- package/dist/src/browser.d.ts +2 -1
- package/dist/src/browser.js +23 -5
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/src/browser-state.ts +74 -4
- package/src/browser.ts +16 -5
- package/src/index.ts +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
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
|
|
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
|
|
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}`));
|
package/dist/src/browser.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/browser.js
CHANGED
|
@@ -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 ?
|
|
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);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/src/browser-state.ts
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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';
|