@mochi.js/core 0.3.0 → 0.6.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 +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
package/src/page.ts
CHANGED
|
@@ -38,7 +38,6 @@ import type {
|
|
|
38
38
|
PierceDomNode,
|
|
39
39
|
RemoteObject,
|
|
40
40
|
} from "./cdp/types";
|
|
41
|
-
import { NotImplementedError } from "./errors";
|
|
42
41
|
import { ElementHandle } from "./page/element-handle";
|
|
43
42
|
import { findPiercingMatches } from "./page/piercing";
|
|
44
43
|
import { parseSelector } from "./page/selector";
|
|
@@ -92,12 +91,16 @@ export interface PageInit {
|
|
|
92
91
|
/** Initial URL (typically "about:blank"). */
|
|
93
92
|
initialUrl: string;
|
|
94
93
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* `Page.
|
|
98
|
-
*
|
|
94
|
+
* Legacy field — preserved as optional for any out-of-tree caller that
|
|
95
|
+
* still constructs a Page with it. Task 0266 retired the per-page
|
|
96
|
+
* `Page.addScriptToEvaluateOnNewDocument` install in favour of the
|
|
97
|
+
* session-level `Fetch.fulfillRequest` body splice; the field is no
|
|
98
|
+
* longer set by `Session.newPage()` and the legacy
|
|
99
|
+
* `removeScriptToEvaluateOnNewDocument` cleanup in `close()` is a no-op
|
|
100
|
+
* when this is unset.
|
|
99
101
|
*
|
|
100
|
-
*
|
|
102
|
+
* @deprecated retained only for backward compatibility; remove after a
|
|
103
|
+
* full deprecation cycle.
|
|
101
104
|
*/
|
|
102
105
|
injectScriptIdentifier?: string;
|
|
103
106
|
/**
|
|
@@ -156,6 +159,152 @@ export interface HumanScrollOptions {
|
|
|
156
159
|
duration?: number;
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Options for `Page.screenshot`. Maps directly onto CDP `Page.captureScreenshot`
|
|
164
|
+
* params with a thin compatibility layer for `fullPage` (which CDP doesn't
|
|
165
|
+
* model directly — we synthesize it from `Page.getLayoutMetrics` +
|
|
166
|
+
* `Emulation.setDeviceMetricsOverride`).
|
|
167
|
+
*
|
|
168
|
+
* @see PLAN.md §8.2 — `Page.captureScreenshot` is NOT on the forbidden list
|
|
169
|
+
* (only `Runtime.enable` and `Page.createIsolatedWorld` are).
|
|
170
|
+
*/
|
|
171
|
+
export interface ScreenshotOptions {
|
|
172
|
+
/** Image format. Default `"png"`. */
|
|
173
|
+
format?: "png" | "jpeg" | "webp";
|
|
174
|
+
/**
|
|
175
|
+
* Compression quality, 0..100. JPEG/WebP only — silently ignored for PNG by
|
|
176
|
+
* the CDP layer (we still pass it through; CDP just drops it).
|
|
177
|
+
*/
|
|
178
|
+
quality?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Capture beyond the visible viewport — i.e. the full document height.
|
|
181
|
+
* Implementation: `Page.getLayoutMetrics` for content size, override the
|
|
182
|
+
* device metrics to that size via `Emulation.setDeviceMetricsOverride`,
|
|
183
|
+
* capture, then `Emulation.clearDeviceMetricsOverride` to restore (always,
|
|
184
|
+
* even on capture failure).
|
|
185
|
+
*/
|
|
186
|
+
fullPage?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* Capture only a rectangular region (CSS pixels). Mutually exclusive with
|
|
189
|
+
* `fullPage` — if both are set, `clip` wins (CDP semantics).
|
|
190
|
+
*/
|
|
191
|
+
clip?: { x: number; y: number; width: number; height: number; scale?: number };
|
|
192
|
+
/**
|
|
193
|
+
* Render the page background as transparent (PNG only). For JPEG this is a
|
|
194
|
+
* no-op since JPEG has no alpha channel.
|
|
195
|
+
*/
|
|
196
|
+
omitBackground?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Output encoding. `"binary"` (default) returns `Uint8Array`; `"base64"`
|
|
199
|
+
* returns the raw CDP base64 string. The discriminated overloads of
|
|
200
|
+
* `Page.screenshot` narrow the return type accordingly.
|
|
201
|
+
*/
|
|
202
|
+
encoding?: "binary" | "base64";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- DX cluster: DOM storage + permissions (task 0257) ---------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Options for {@link Page.localStorage} / {@link Page.sessionStorage}
|
|
209
|
+
* accessors. Both default to the page's main-frame origin (read at call time
|
|
210
|
+
* from `window.location.origin`); pass `origin` to read/write a different
|
|
211
|
+
* frame's storage explicitly.
|
|
212
|
+
*/
|
|
213
|
+
export interface DomStorageOptions {
|
|
214
|
+
/**
|
|
215
|
+
* Origin to scope the storage read/write to (e.g. `"https://example.com"`).
|
|
216
|
+
* Default: the page's current main-frame origin.
|
|
217
|
+
*
|
|
218
|
+
* Required when the call must hit a *different* origin's storage (e.g.
|
|
219
|
+
* cross-origin warm-session restore). If the page hasn't navigated yet
|
|
220
|
+
* (origin = `"about:blank"`), an explicit `origin` is required.
|
|
221
|
+
*/
|
|
222
|
+
origin?: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The shape returned by {@link Page.localStorage} / {@link Page.sessionStorage}.
|
|
227
|
+
* Both accessors return the same surface — the only difference is the
|
|
228
|
+
* `isLocalStorage` flag CDP receives under the hood.
|
|
229
|
+
*
|
|
230
|
+
* Backed by `DOMStorage.getDOMStorageItems` / `DOMStorage.setDOMStorageItem`.
|
|
231
|
+
* Per CDP, the response shape for `getDOMStorageItems` is
|
|
232
|
+
* `{ entries: [string, string][] }` — we collapse that to a `Record` for
|
|
233
|
+
* Bun-native ergonomics.
|
|
234
|
+
*
|
|
235
|
+
* @see https://chromedevtools.github.io/devtools-protocol/tot/DOMStorage/
|
|
236
|
+
*/
|
|
237
|
+
export interface DomStorage {
|
|
238
|
+
/**
|
|
239
|
+
* Read every key/value pair currently in the (local|session)Storage of the
|
|
240
|
+
* scoped origin. Default scope: the page's main-frame origin at call time.
|
|
241
|
+
*/
|
|
242
|
+
get(opts?: DomStorageOptions): Promise<Record<string, string>>;
|
|
243
|
+
/**
|
|
244
|
+
* Write each key in `items` to the scoped origin's storage. Existing keys
|
|
245
|
+
* not mentioned in `items` are left untouched (this is `Object.assign`
|
|
246
|
+
* semantics, not `replace`). To clear, set the key explicitly to `""` or
|
|
247
|
+
* fetch via {@link get} → mutate → call {@link set} with the union.
|
|
248
|
+
*/
|
|
249
|
+
set(items: Record<string, string>, opts?: DomStorageOptions): Promise<void>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every browser-level permission descriptor `Browser.grantPermissions` accepts.
|
|
254
|
+
*
|
|
255
|
+
* Pinned to the CDP `Browser.PermissionType` enum on Chromium ≥ 131 (the
|
|
256
|
+
* mochi profile floor — same baseline the worker idOnly bootstrap relies on).
|
|
257
|
+
* The list is verbose-on-purpose: we want a contract test to catch the day
|
|
258
|
+
* Chromium adds a new permission so we can decide whether to forward it.
|
|
259
|
+
*
|
|
260
|
+
* @see https://chromedevtools.github.io/devtools-protocol/tot/Browser/#type-PermissionType
|
|
261
|
+
*/
|
|
262
|
+
export const ALL_BROWSER_PERMISSIONS = [
|
|
263
|
+
"accessibilityEvents",
|
|
264
|
+
"audioCapture",
|
|
265
|
+
"backgroundSync",
|
|
266
|
+
"backgroundFetch",
|
|
267
|
+
"captureHandle",
|
|
268
|
+
"clipboardReadWrite",
|
|
269
|
+
"clipboardSanitizedWrite",
|
|
270
|
+
"displayCapture",
|
|
271
|
+
"durableStorage",
|
|
272
|
+
"flash",
|
|
273
|
+
"geolocation",
|
|
274
|
+
"idleDetection",
|
|
275
|
+
"localFonts",
|
|
276
|
+
"midi",
|
|
277
|
+
"midiSysex",
|
|
278
|
+
"nfc",
|
|
279
|
+
"notifications",
|
|
280
|
+
"paymentHandler",
|
|
281
|
+
"periodicBackgroundSync",
|
|
282
|
+
"protectedMediaIdentifier",
|
|
283
|
+
"sensors",
|
|
284
|
+
"storageAccess",
|
|
285
|
+
"speakerSelection",
|
|
286
|
+
"topLevelStorageAccess",
|
|
287
|
+
"videoCapture",
|
|
288
|
+
"videoCapturePanTiltZoom",
|
|
289
|
+
"wakeLockScreen",
|
|
290
|
+
"wakeLockSystem",
|
|
291
|
+
"webAppInstallation",
|
|
292
|
+
"windowManagement",
|
|
293
|
+
] as const;
|
|
294
|
+
|
|
295
|
+
/** A single entry from {@link ALL_BROWSER_PERMISSIONS}. */
|
|
296
|
+
export type BrowserPermission = (typeof ALL_BROWSER_PERMISSIONS)[number];
|
|
297
|
+
|
|
298
|
+
/** Options for {@link Page.grantAllPermissions}. */
|
|
299
|
+
export interface GrantAllPermissionsOptions {
|
|
300
|
+
/**
|
|
301
|
+
* Origin to grant permissions to. Default: the page's current main-frame
|
|
302
|
+
* origin (read at call time). When `about:blank`, an explicit `origin` is
|
|
303
|
+
* required — `Browser.grantPermissions` rejects opaque origins.
|
|
304
|
+
*/
|
|
305
|
+
origin?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
159
308
|
export class Page {
|
|
160
309
|
private readonly router: MessageRouter;
|
|
161
310
|
private readonly targetId: string;
|
|
@@ -194,6 +343,10 @@ export class Page {
|
|
|
194
343
|
* — see PLAN.md I-5: behavioral parameters come from MatrixV1.profile.behavior).
|
|
195
344
|
*/
|
|
196
345
|
private cursor: { x: number; y: number };
|
|
346
|
+
/** localStorage namespace returned by the {@link localStorage} getter. */
|
|
347
|
+
private readonly localStorageJar: DomStorage;
|
|
348
|
+
/** sessionStorage namespace returned by the {@link sessionStorage} getter. */
|
|
349
|
+
private readonly sessionStorageJar: DomStorage;
|
|
197
350
|
|
|
198
351
|
constructor(init: PageInit) {
|
|
199
352
|
this.router = init.router;
|
|
@@ -204,6 +357,10 @@ export class Page {
|
|
|
204
357
|
this.behavior = init.behavior ?? DEFAULT_BEHAVIOR_PROFILE;
|
|
205
358
|
this.seed = init.seed ?? "default";
|
|
206
359
|
this.cursor = init.initialCursor ?? { x: 0, y: 0 };
|
|
360
|
+
// Bind both DOM-storage namespaces once. The `isLocalStorage` flag
|
|
361
|
+
// routes the same plumbing to local vs session storage on the CDP side.
|
|
362
|
+
this.localStorageJar = createDomStorage(this, true);
|
|
363
|
+
this.sessionStorageJar = createDomStorage(this, false);
|
|
207
364
|
this.subscribeFrameTopology();
|
|
208
365
|
}
|
|
209
366
|
|
|
@@ -365,6 +522,67 @@ export class Page {
|
|
|
365
522
|
return result.cookies;
|
|
366
523
|
}
|
|
367
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Per-origin localStorage accessor — `get()` and `set(items)`. Backed by
|
|
527
|
+
* `DOMStorage.getDOMStorageItems` / `DOMStorage.setDOMStorageItem`. Frame
|
|
528
|
+
* scope defaults to the page's current main-frame origin; pass
|
|
529
|
+
* `{ origin }` to target a different frame's storage. See {@link DomStorage}.
|
|
530
|
+
*
|
|
531
|
+
* Use cases (per `docs/audits/nodriver.md` LOW finding 3):
|
|
532
|
+
* - "returning visitor" warming: seed `lastVisit`, A/B-test bucket,
|
|
533
|
+
* consent-banner dismissal.
|
|
534
|
+
* - Capture + replay across runs by serializing the `Record` to disk.
|
|
535
|
+
*
|
|
536
|
+
* Sister surface: {@link sessionStorage} — same shape, hits sessionStorage
|
|
537
|
+
* via the `isLocalStorage: false` CDP flag.
|
|
538
|
+
*/
|
|
539
|
+
get localStorage(): DomStorage {
|
|
540
|
+
return this.localStorageJar;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Per-origin sessionStorage accessor. Same shape as {@link localStorage}
|
|
545
|
+
* but hits sessionStorage via `DOMStorage.getDOMStorageItems` /
|
|
546
|
+
* `DOMStorage.setDOMStorageItem` with `isLocalStorage: false`. Note
|
|
547
|
+
* sessionStorage is per-tab — values written here vanish when the page is
|
|
548
|
+
* closed, exactly as in a regular browsing session.
|
|
549
|
+
*/
|
|
550
|
+
get sessionStorage(): DomStorage {
|
|
551
|
+
return this.sessionStorageJar;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Grant every permission `Browser.grantPermissions` accepts (the full
|
|
556
|
+
* descriptor list pinned by {@link ALL_BROWSER_PERMISSIONS}) to the
|
|
557
|
+
* scoped origin. Defaults to the page's current main-frame origin; pass
|
|
558
|
+
* `{ origin }` to grant explicitly.
|
|
559
|
+
*
|
|
560
|
+
* Pairs with R-036 (the per-permission `navigator.permissions.query()`
|
|
561
|
+
* spoof in `@mochi.js/inject/src/modules/permissions.ts`): this method
|
|
562
|
+
* grants ALL at the *browser* level (so the page never sees a permission
|
|
563
|
+
* prompt), but the page-side `query()` matrix still returns whatever
|
|
564
|
+
* `matrix.uaCh["permissions-defaults"]` says. The two surfaces are
|
|
565
|
+
* orthogonal — the inject module decides what the page *sees*; this method
|
|
566
|
+
* decides what the browser *enforces*.
|
|
567
|
+
*
|
|
568
|
+
* Throws when the page hasn't navigated yet (`about:blank` resolves to no
|
|
569
|
+
* usable origin) and no `origin` was passed explicitly — the CDP method
|
|
570
|
+
* rejects opaque origins.
|
|
571
|
+
*
|
|
572
|
+
* @see docs/audits/nodriver.md LOW finding 4 (`Browser.grant_all_permissions`).
|
|
573
|
+
*/
|
|
574
|
+
async grantAllPermissions(opts: GrantAllPermissionsOptions = {}): Promise<void> {
|
|
575
|
+
this.assertOpen();
|
|
576
|
+
const origin = opts.origin ?? (await this.resolveOrigin("grantAllPermissions"));
|
|
577
|
+
// Browser.grantPermissions runs on the ROOT browser target — it's not a
|
|
578
|
+
// page-scoped method. The router's `sessionId` defaults to the root
|
|
579
|
+
// browser target when omitted, which is exactly what we want here.
|
|
580
|
+
await this.router.send("Browser.grantPermissions", {
|
|
581
|
+
permissions: [...ALL_BROWSER_PERMISSIONS],
|
|
582
|
+
origin,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
368
586
|
/**
|
|
369
587
|
* Install an additional main-world script that runs on every new document
|
|
370
588
|
* via `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true,
|
|
@@ -875,8 +1093,95 @@ export class Page {
|
|
|
875
1093
|
return handles;
|
|
876
1094
|
}
|
|
877
1095
|
|
|
878
|
-
|
|
879
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* Capture a screenshot of the page via CDP `Page.captureScreenshot`.
|
|
1098
|
+
*
|
|
1099
|
+
* Default: PNG-encoded `Uint8Array` of the visible viewport. Pass
|
|
1100
|
+
* `fullPage: true` to capture beyond the viewport (we round-trip through
|
|
1101
|
+
* `Emulation.setDeviceMetricsOverride` and restore via
|
|
1102
|
+
* `Emulation.clearDeviceMetricsOverride` afterwards — guaranteed even on
|
|
1103
|
+
* capture failure). Pass `encoding: "base64"` to skip the base64 → bytes
|
|
1104
|
+
* decode and get the raw CDP string back.
|
|
1105
|
+
*
|
|
1106
|
+
* Out of scope at v0.2 (tracked separately):
|
|
1107
|
+
* - Element-bounded screenshot (`{ element: handle }`) — needs
|
|
1108
|
+
* `DOM.getBoxModel` integration.
|
|
1109
|
+
* - PDF generation — `Page.printToPDF` lives in its own brief.
|
|
1110
|
+
*
|
|
1111
|
+
* @see PLAN.md §8.2 — `Page.captureScreenshot` is permitted; only
|
|
1112
|
+
* `Runtime.enable` and `Page.createIsolatedWorld` are forbidden.
|
|
1113
|
+
*/
|
|
1114
|
+
screenshot(opts: ScreenshotOptions & { encoding: "base64" }): Promise<string>;
|
|
1115
|
+
screenshot(opts?: ScreenshotOptions & { encoding?: "binary" }): Promise<Uint8Array>;
|
|
1116
|
+
async screenshot(opts: ScreenshotOptions = {}): Promise<Uint8Array | string> {
|
|
1117
|
+
this.assertOpen();
|
|
1118
|
+
const format = opts.format ?? "png";
|
|
1119
|
+
// CDP `Page.captureScreenshot` params. We pass `captureBeyondViewport`
|
|
1120
|
+
// for fullPage *in addition to* the device-metrics override below — the
|
|
1121
|
+
// override changes the layout viewport for the capture, while
|
|
1122
|
+
// `captureBeyondViewport` lets the renderer paint past the visible area
|
|
1123
|
+
// for the duration of the capture (belt-and-braces; either alone has
|
|
1124
|
+
// edge cases on long pages).
|
|
1125
|
+
const params: Record<string, unknown> = { format };
|
|
1126
|
+
if (opts.quality !== undefined && (format === "jpeg" || format === "webp")) {
|
|
1127
|
+
params.quality = opts.quality;
|
|
1128
|
+
}
|
|
1129
|
+
if (opts.clip !== undefined) {
|
|
1130
|
+
// CDP requires `scale` — default 1 if caller didn't set it.
|
|
1131
|
+
params.clip = { ...opts.clip, scale: opts.clip.scale ?? 1 };
|
|
1132
|
+
}
|
|
1133
|
+
if (opts.omitBackground === true) {
|
|
1134
|
+
params.omitBackground = true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// fullPage round-trip. We capture the layout metrics first, then size
|
|
1138
|
+
// the device viewport up to the content size, capture, then clear the
|
|
1139
|
+
// override. The `try/finally` is load-bearing — if `captureScreenshot`
|
|
1140
|
+
// throws (e.g. target detached mid-capture) we still need to restore
|
|
1141
|
+
// the viewport so subsequent calls don't see a frozen oversized layout.
|
|
1142
|
+
let restoreOverride = false;
|
|
1143
|
+
if (opts.fullPage === true && opts.clip === undefined) {
|
|
1144
|
+
const metrics = await this.send<{
|
|
1145
|
+
contentSize: { width: number; height: number };
|
|
1146
|
+
layoutViewport: { clientWidth: number; clientHeight: number };
|
|
1147
|
+
}>("Page.getLayoutMetrics");
|
|
1148
|
+
const width = Math.ceil(metrics.contentSize.width);
|
|
1149
|
+
const height = Math.ceil(metrics.contentSize.height);
|
|
1150
|
+
await this.send("Emulation.setDeviceMetricsOverride", {
|
|
1151
|
+
width,
|
|
1152
|
+
height,
|
|
1153
|
+
deviceScaleFactor: 0,
|
|
1154
|
+
mobile: false,
|
|
1155
|
+
});
|
|
1156
|
+
restoreOverride = true;
|
|
1157
|
+
params.captureBeyondViewport = true;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let result: { data: string };
|
|
1161
|
+
try {
|
|
1162
|
+
result = await this.send<{ data: string }>("Page.captureScreenshot", params);
|
|
1163
|
+
} finally {
|
|
1164
|
+
if (restoreOverride) {
|
|
1165
|
+
// Always clear, even if capture threw. Best-effort: if the target is
|
|
1166
|
+
// gone the clear will fail and we swallow it — the page is unusable
|
|
1167
|
+
// anyway and the override dies with the target.
|
|
1168
|
+
try {
|
|
1169
|
+
await this.send("Emulation.clearDeviceMetricsOverride");
|
|
1170
|
+
} catch {
|
|
1171
|
+
// ignore
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const encoding = opts.encoding ?? "binary";
|
|
1177
|
+
if (encoding === "base64") {
|
|
1178
|
+
return result.data;
|
|
1179
|
+
}
|
|
1180
|
+
// Decode base64 → bytes via Bun-native `Buffer.from`. The Buffer is a
|
|
1181
|
+
// Uint8Array subclass; we slice into a plain Uint8Array view backed by
|
|
1182
|
+
// the same memory so the public type is the standard one.
|
|
1183
|
+
const buf = Buffer.from(result.data, "base64");
|
|
1184
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
880
1185
|
}
|
|
881
1186
|
|
|
882
1187
|
// ---- internals --------------------------------------------------------------
|
|
@@ -886,6 +1191,52 @@ export class Page {
|
|
|
886
1191
|
return this.router.send<T>(method, params, { sessionId: this.sessionId });
|
|
887
1192
|
}
|
|
888
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* Resolve the page's main-frame origin via `Runtime.callFunctionOn` against
|
|
1196
|
+
* the document objectId. Used by {@link grantAllPermissions} and the DOM
|
|
1197
|
+
* storage namespaces when the caller didn't pass an explicit `origin`.
|
|
1198
|
+
*
|
|
1199
|
+
* Throws with a precise diagnostic when the origin is opaque
|
|
1200
|
+
* (`about:blank` / `data:` URLs) — the consumers can't fall back to
|
|
1201
|
+
* "current page" because there's nothing meaningful to scope.
|
|
1202
|
+
*/
|
|
1203
|
+
private async resolveOrigin(callerName: string): Promise<string> {
|
|
1204
|
+
const docId = await this.documentObjectId();
|
|
1205
|
+
const r = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
|
|
1206
|
+
objectId: docId,
|
|
1207
|
+
functionDeclaration:
|
|
1208
|
+
"function() { return (this.defaultView || window).location && (this.defaultView || window).location.origin || ''; }",
|
|
1209
|
+
returnByValue: true,
|
|
1210
|
+
});
|
|
1211
|
+
const v = r.result.value;
|
|
1212
|
+
if (typeof v !== "string" || v.length === 0 || v === "null") {
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
`[mochi] page.${callerName}: page origin is opaque (likely about:blank). Pass { origin } explicitly.`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
return v;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Module-private accessor used by {@link createDomStorage}. Mirrors the
|
|
1222
|
+
* cookie-jar plumbing pattern on Session — the factory lives in module
|
|
1223
|
+
* scope so its return type can be the public {@link DomStorage} interface
|
|
1224
|
+
* without leaking implementation onto the Page surface.
|
|
1225
|
+
*
|
|
1226
|
+
* @internal
|
|
1227
|
+
*/
|
|
1228
|
+
_internalDomStoragePlumbing(): {
|
|
1229
|
+
send: <T>(method: string, params?: unknown) => Promise<T>;
|
|
1230
|
+
resolveOrigin: (caller: string) => Promise<string>;
|
|
1231
|
+
assertOpen: () => void;
|
|
1232
|
+
} {
|
|
1233
|
+
return {
|
|
1234
|
+
send: <T>(method: string, params?: unknown) => this.send<T>(method, params),
|
|
1235
|
+
resolveOrigin: (caller: string) => this.resolveOrigin(caller),
|
|
1236
|
+
assertOpen: () => this.assertOpen(),
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
889
1240
|
/** Subscribe to frame events to keep `currentUrl` and `mainFrameId` fresh. */
|
|
890
1241
|
private subscribeFrameTopology(): void {
|
|
891
1242
|
this.router.on("Page.frameNavigated", (params, sessionId) => {
|
|
@@ -1128,3 +1479,54 @@ function hash01(s: string): number {
|
|
|
1128
1479
|
}
|
|
1129
1480
|
return (h >>> 0) / 0x1_0000_0000;
|
|
1130
1481
|
}
|
|
1482
|
+
|
|
1483
|
+
// ---- DOM storage factory (task 0257) ----------------------------------------
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Build the {@link DomStorage} returned by `Page.localStorage` /
|
|
1487
|
+
* `Page.sessionStorage`. Bound to one Page instance via
|
|
1488
|
+
* {@link Page._internalDomStoragePlumbing}. Module-private; the public surface
|
|
1489
|
+
* is the interface — instances are only created by the Page constructor.
|
|
1490
|
+
*
|
|
1491
|
+
* `isLocalStorage` flag picks the CDP storage backing:
|
|
1492
|
+
* - `true` → `localStorage` (the persistent per-origin store).
|
|
1493
|
+
* - `false` → `sessionStorage` (the per-tab transient store).
|
|
1494
|
+
*
|
|
1495
|
+
* @internal
|
|
1496
|
+
*/
|
|
1497
|
+
function createDomStorage(page: Page, isLocalStorage: boolean): DomStorage {
|
|
1498
|
+
const { send, resolveOrigin, assertOpen } = page._internalDomStoragePlumbing();
|
|
1499
|
+
const callerName = isLocalStorage ? "localStorage" : "sessionStorage";
|
|
1500
|
+
return {
|
|
1501
|
+
async get(opts: DomStorageOptions = {}) {
|
|
1502
|
+
assertOpen();
|
|
1503
|
+
const securityOrigin = opts.origin ?? (await resolveOrigin(`${callerName}.get`));
|
|
1504
|
+
const result = await send<{ entries: Array<[string, string]> }>(
|
|
1505
|
+
"DOMStorage.getDOMStorageItems",
|
|
1506
|
+
{ storageId: { securityOrigin, isLocalStorage } },
|
|
1507
|
+
);
|
|
1508
|
+
// CDP returns `[ [k, v], ... ]`. Collapse to a Record for ergonomics.
|
|
1509
|
+
const out: Record<string, string> = {};
|
|
1510
|
+
for (const entry of result.entries) {
|
|
1511
|
+
const k = entry[0];
|
|
1512
|
+
const v = entry[1];
|
|
1513
|
+
if (typeof k === "string" && typeof v === "string") out[k] = v;
|
|
1514
|
+
}
|
|
1515
|
+
return out;
|
|
1516
|
+
},
|
|
1517
|
+
async set(items: Record<string, string>, opts: DomStorageOptions = {}) {
|
|
1518
|
+
assertOpen();
|
|
1519
|
+
const securityOrigin = opts.origin ?? (await resolveOrigin(`${callerName}.set`));
|
|
1520
|
+
// CDP's `setDOMStorageItem` takes one key/value at a time. We fan out
|
|
1521
|
+
// sequentially so a partial failure (e.g. a too-large value) surfaces
|
|
1522
|
+
// with the offending key in the error frame.
|
|
1523
|
+
for (const [k, v] of Object.entries(items)) {
|
|
1524
|
+
await send("DOMStorage.setDOMStorageItem", {
|
|
1525
|
+
storageId: { securityOrigin, isLocalStorage },
|
|
1526
|
+
key: k,
|
|
1527
|
+
value: v,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1532
|
+
}
|
package/src/proc.ts
CHANGED
|
@@ -108,8 +108,35 @@ export interface SpawnConfig {
|
|
|
108
108
|
binary: string;
|
|
109
109
|
/** User-supplied extra flags appended after the defaults. Null to skip. */
|
|
110
110
|
extraArgs?: readonly string[];
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Legacy boolean knob. `true` → emit `--headless=new`. Retained for
|
|
113
|
+
* backward compatibility with v0.1 callers. New code should set
|
|
114
|
+
* {@link headlessMode} instead — it carries strictly more information
|
|
115
|
+
* (`"new" | "legacy" | "off"`) and supersedes this field when both are set.
|
|
116
|
+
*/
|
|
112
117
|
headless: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Headless dispatch mode. Takes precedence over {@link headless} when set.
|
|
120
|
+
*
|
|
121
|
+
* - `"new"` → emit `--headless=new` (modern headless: full rendering,
|
|
122
|
+
* near-byte-identical to headful for fingerprinting).
|
|
123
|
+
* - `"legacy"` → emit bare `--headless` (legacy headless code path; more
|
|
124
|
+
* detectable but useful for parity with older tooling).
|
|
125
|
+
* - `"off"` → emit no headless flag (run headful — requires a display
|
|
126
|
+
* server / xvfb).
|
|
127
|
+
*
|
|
128
|
+
* Resolution at the launch layer:
|
|
129
|
+
*
|
|
130
|
+
* 1. If the caller passes `headlessMode` explicitly, it wins.
|
|
131
|
+
* 2. Else if the caller passes the legacy `headless: true | false`, it
|
|
132
|
+
* maps to `"new"` / `"off"` respectively.
|
|
133
|
+
* 3. Else the launcher inspects the live env: Linux without DISPLAY /
|
|
134
|
+
* WAYLAND_DISPLAY → `"new"`; everywhere else → `"off"`.
|
|
135
|
+
*
|
|
136
|
+
* @see tasks/0258 (Linux server env auto-detection)
|
|
137
|
+
* @see docs/getting-started/linux-server.md
|
|
138
|
+
*/
|
|
139
|
+
headlessMode?: "new" | "legacy" | "off";
|
|
113
140
|
/** Optional proxy server, e.g. "http://host:port" or "socks5://host:port". */
|
|
114
141
|
proxy?: string;
|
|
115
142
|
/**
|
|
@@ -389,12 +416,28 @@ export function buildChromiumArgs(
|
|
|
389
416
|
if (cfg.hermetic === true) {
|
|
390
417
|
args.push(...HERMETIC_ONLY_CHROMIUM_FLAGS);
|
|
391
418
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
419
|
+
// Headless dispatch.
|
|
420
|
+
//
|
|
421
|
+
// `headlessMode` (task 0258) supersedes the legacy `headless: boolean` knob
|
|
422
|
+
// when both are set. When `headlessMode` is unset, fall back to the v0.1
|
|
423
|
+
// mapping (`headless: true → "new"`, `false → "off"`). The launcher in
|
|
424
|
+
// `launch.ts` is responsible for the env-aware default ("new" on Linux
|
|
425
|
+
// server, "off" on dev workstation with DISPLAY); by the time we reach
|
|
426
|
+
// here, `headlessMode` is the resolved decision.
|
|
427
|
+
//
|
|
428
|
+
// `--headless=new` is the modern code path (full rendering, near-byte-
|
|
429
|
+
// identical to headful for fingerprinting). The bare `--headless` is the
|
|
430
|
+
// legacy mode — separate, more-detectable code path; we expose it for
|
|
431
|
+
// parity-with-older-tooling needs but never default to it. Source-cited
|
|
432
|
+
// from Chromium HeadlessShell `--headless=new` migration notes (Chromium
|
|
433
|
+
// M118+).
|
|
434
|
+
const mode = cfg.headlessMode ?? (cfg.headless ? "new" : "off");
|
|
435
|
+
if (mode === "new") {
|
|
396
436
|
args.push("--headless=new");
|
|
437
|
+
} else if (mode === "legacy") {
|
|
438
|
+
args.push("--headless");
|
|
397
439
|
}
|
|
440
|
+
// mode === "off" → no headless flag.
|
|
398
441
|
if (cfg.proxy !== undefined && cfg.proxy.length > 0) {
|
|
399
442
|
args.push(`--proxy-server=${cfg.proxy}`);
|
|
400
443
|
}
|