@mochi.js/core 0.3.0 → 0.8.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/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
- * Identifier returned by `Page.addScriptToEvaluateOnNewDocument` when the
96
- * inject payload was installed at session-newPage time. Tracked here so
97
- * `Page.close()` can call `Page.removeScriptToEvaluateOnNewDocument`
98
- * required by PLAN.md §8.4 to keep the per-target identifier list bounded.
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
- * Optional: zero-spoofing test setups (or future no-inject paths) may omit.
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,170 @@ 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 ---------------------
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 148 (the
256
+ * post-0.7 profile floor — verified `2026-05-09` against the live CDP
257
+ * reference). The list is verbose-on-purpose: we want a contract test
258
+ * to catch the day Chromium adds a new permission so we can decide
259
+ * whether to forward it.
260
+ *
261
+ * Drift history:
262
+ * - 0.7: removed `accessibilityEvents`, `captureHandle`, `flash`,
263
+ * `videoCapturePanTiltZoom` (gone or renamed in 148). Added the XR
264
+ * cluster (`ar`, `vr`, `handTracking`), `automaticFullscreen`,
265
+ * `cameraPanTiltZoom`, `capturedSurfaceControl`, `keyboardLock`,
266
+ * `pointerLock`, `localNetwork`, `localNetworkAccess`,
267
+ * `loopbackNetwork`, `smartCard`, `webPrinting`.
268
+ *
269
+ * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser/#type-PermissionType
270
+ */
271
+ export const ALL_BROWSER_PERMISSIONS = [
272
+ "ar",
273
+ "audioCapture",
274
+ "automaticFullscreen",
275
+ "backgroundFetch",
276
+ "backgroundSync",
277
+ "cameraPanTiltZoom",
278
+ "capturedSurfaceControl",
279
+ "clipboardReadWrite",
280
+ "clipboardSanitizedWrite",
281
+ "displayCapture",
282
+ "durableStorage",
283
+ "geolocation",
284
+ "handTracking",
285
+ "idleDetection",
286
+ "keyboardLock",
287
+ "localFonts",
288
+ "localNetwork",
289
+ "localNetworkAccess",
290
+ "loopbackNetwork",
291
+ "midi",
292
+ "midiSysex",
293
+ "nfc",
294
+ "notifications",
295
+ "paymentHandler",
296
+ "periodicBackgroundSync",
297
+ "pointerLock",
298
+ "protectedMediaIdentifier",
299
+ "sensors",
300
+ "smartCard",
301
+ "speakerSelection",
302
+ "storageAccess",
303
+ "topLevelStorageAccess",
304
+ "videoCapture",
305
+ "vr",
306
+ "wakeLockScreen",
307
+ "wakeLockSystem",
308
+ "webAppInstallation",
309
+ "webPrinting",
310
+ "windowManagement",
311
+ ] as const;
312
+
313
+ /** A single entry from {@link ALL_BROWSER_PERMISSIONS}. */
314
+ export type BrowserPermission = (typeof ALL_BROWSER_PERMISSIONS)[number];
315
+
316
+ /** Options for {@link Page.grantAllPermissions}. */
317
+ export interface GrantAllPermissionsOptions {
318
+ /**
319
+ * Origin to grant permissions to. Default: the page's current main-frame
320
+ * origin (read at call time). When `about:blank`, an explicit `origin` is
321
+ * required — `Browser.grantPermissions` rejects opaque origins.
322
+ */
323
+ origin?: string;
324
+ }
325
+
159
326
  export class Page {
160
327
  private readonly router: MessageRouter;
161
328
  private readonly targetId: string;
@@ -194,6 +361,10 @@ export class Page {
194
361
  * — see PLAN.md I-5: behavioral parameters come from MatrixV1.profile.behavior).
195
362
  */
196
363
  private cursor: { x: number; y: number };
364
+ /** localStorage namespace returned by the {@link localStorage} getter. */
365
+ private readonly localStorageJar: DomStorage;
366
+ /** sessionStorage namespace returned by the {@link sessionStorage} getter. */
367
+ private readonly sessionStorageJar: DomStorage;
197
368
 
198
369
  constructor(init: PageInit) {
199
370
  this.router = init.router;
@@ -204,6 +375,10 @@ export class Page {
204
375
  this.behavior = init.behavior ?? DEFAULT_BEHAVIOR_PROFILE;
205
376
  this.seed = init.seed ?? "default";
206
377
  this.cursor = init.initialCursor ?? { x: 0, y: 0 };
378
+ // Bind both DOM-storage namespaces once. The `isLocalStorage` flag
379
+ // routes the same plumbing to local vs session storage on the CDP side.
380
+ this.localStorageJar = createDomStorage(this, true);
381
+ this.sessionStorageJar = createDomStorage(this, false);
207
382
  this.subscribeFrameTopology();
208
383
  }
209
384
 
@@ -365,6 +540,67 @@ export class Page {
365
540
  return result.cookies;
366
541
  }
367
542
 
543
+ /**
544
+ * Per-origin localStorage accessor — `get()` and `set(items)`. Backed by
545
+ * `DOMStorage.getDOMStorageItems` / `DOMStorage.setDOMStorageItem`. Frame
546
+ * scope defaults to the page's current main-frame origin; pass
547
+ * `{ origin }` to target a different frame's storage. See {@link DomStorage}.
548
+ *
549
+ * Use cases (per `docs/audits/nodriver.md` LOW finding 3):
550
+ * - "returning visitor" warming: seed `lastVisit`, A/B-test bucket,
551
+ * consent-banner dismissal.
552
+ * - Capture + replay across runs by serializing the `Record` to disk.
553
+ *
554
+ * Sister surface: {@link sessionStorage} — same shape, hits sessionStorage
555
+ * via the `isLocalStorage: false` CDP flag.
556
+ */
557
+ get localStorage(): DomStorage {
558
+ return this.localStorageJar;
559
+ }
560
+
561
+ /**
562
+ * Per-origin sessionStorage accessor. Same shape as {@link localStorage}
563
+ * but hits sessionStorage via `DOMStorage.getDOMStorageItems` /
564
+ * `DOMStorage.setDOMStorageItem` with `isLocalStorage: false`. Note
565
+ * sessionStorage is per-tab — values written here vanish when the page is
566
+ * closed, exactly as in a regular browsing session.
567
+ */
568
+ get sessionStorage(): DomStorage {
569
+ return this.sessionStorageJar;
570
+ }
571
+
572
+ /**
573
+ * Grant every permission `Browser.grantPermissions` accepts (the full
574
+ * descriptor list pinned by {@link ALL_BROWSER_PERMISSIONS}) to the
575
+ * scoped origin. Defaults to the page's current main-frame origin; pass
576
+ * `{ origin }` to grant explicitly.
577
+ *
578
+ * Pairs with R-036 (the per-permission `navigator.permissions.query()`
579
+ * spoof in `@mochi.js/inject/src/modules/permissions.ts`): this method
580
+ * grants ALL at the *browser* level (so the page never sees a permission
581
+ * prompt), but the page-side `query()` matrix still returns whatever
582
+ * `matrix.uaCh["permissions-defaults"]` says. The two surfaces are
583
+ * orthogonal — the inject module decides what the page *sees*; this method
584
+ * decides what the browser *enforces*.
585
+ *
586
+ * Throws when the page hasn't navigated yet (`about:blank` resolves to no
587
+ * usable origin) and no `origin` was passed explicitly — the CDP method
588
+ * rejects opaque origins.
589
+ *
590
+ * @see docs/audits/nodriver.md LOW finding 4 (`Browser.grant_all_permissions`).
591
+ */
592
+ async grantAllPermissions(opts: GrantAllPermissionsOptions = {}): Promise<void> {
593
+ this.assertOpen();
594
+ const origin = opts.origin ?? (await this.resolveOrigin("grantAllPermissions"));
595
+ // Browser.grantPermissions runs on the ROOT browser target — it's not a
596
+ // page-scoped method. The router's `sessionId` defaults to the root
597
+ // browser target when omitted, which is exactly what we want here.
598
+ await this.router.send("Browser.grantPermissions", {
599
+ permissions: [...ALL_BROWSER_PERMISSIONS],
600
+ origin,
601
+ });
602
+ }
603
+
368
604
  /**
369
605
  * Install an additional main-world script that runs on every new document
370
606
  * via `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true,
@@ -812,13 +1048,12 @@ export class Page {
812
1048
  * Supported selectors (see `selector.ts`): tag / id / class / attribute /
813
1049
  * descendant combinator / comma-separated lists. **Not** supported:
814
1050
  * `>`/`+`/`~` combinators, `:pseudo-classes`, `::pseudo-elements`, XPath.
815
- * XPath is a stretch goal per task 0253 brief — TODO if a future surface
1051
+ * XPath is a stretch goal — TODO if a future surface
816
1052
  * needs it (Turnstile detection only needs CSS).
817
1053
  *
818
1054
  * Performance: O(N) in DOM size per call. Acceptable for v0.2; a per-page
819
1055
  * cache layer is a v0.3+ concern (also called out in 0253).
820
1056
  *
821
- * @see tasks/0253-closed-shadow-piercing-locator.md
822
1057
  * @see PLAN.md §8.2 (`DOM.getDocument` and `DOM.resolveNode` are not on the
823
1058
  * forbidden list — both fine to use here).
824
1059
  */
@@ -875,8 +1110,95 @@ export class Page {
875
1110
  return handles;
876
1111
  }
877
1112
 
878
- screenshot(_opts?: unknown): Promise<Uint8Array> {
879
- return Promise.reject(new NotImplementedError("page.screenshot"));
1113
+ /**
1114
+ * Capture a screenshot of the page via CDP `Page.captureScreenshot`.
1115
+ *
1116
+ * Default: PNG-encoded `Uint8Array` of the visible viewport. Pass
1117
+ * `fullPage: true` to capture beyond the viewport (we round-trip through
1118
+ * `Emulation.setDeviceMetricsOverride` and restore via
1119
+ * `Emulation.clearDeviceMetricsOverride` afterwards — guaranteed even on
1120
+ * capture failure). Pass `encoding: "base64"` to skip the base64 → bytes
1121
+ * decode and get the raw CDP string back.
1122
+ *
1123
+ * Out of scope at v0.2 (tracked separately):
1124
+ * - Element-bounded screenshot (`{ element: handle }`) — needs
1125
+ * `DOM.getBoxModel` integration.
1126
+ * - PDF generation — `Page.printToPDF` lives in its own brief.
1127
+ *
1128
+ * @see PLAN.md §8.2 — `Page.captureScreenshot` is permitted; only
1129
+ * `Runtime.enable` and `Page.createIsolatedWorld` are forbidden.
1130
+ */
1131
+ screenshot(opts: ScreenshotOptions & { encoding: "base64" }): Promise<string>;
1132
+ screenshot(opts?: ScreenshotOptions & { encoding?: "binary" }): Promise<Uint8Array>;
1133
+ async screenshot(opts: ScreenshotOptions = {}): Promise<Uint8Array | string> {
1134
+ this.assertOpen();
1135
+ const format = opts.format ?? "png";
1136
+ // CDP `Page.captureScreenshot` params. We pass `captureBeyondViewport`
1137
+ // for fullPage *in addition to* the device-metrics override below — the
1138
+ // override changes the layout viewport for the capture, while
1139
+ // `captureBeyondViewport` lets the renderer paint past the visible area
1140
+ // for the duration of the capture (belt-and-braces; either alone has
1141
+ // edge cases on long pages).
1142
+ const params: Record<string, unknown> = { format };
1143
+ if (opts.quality !== undefined && (format === "jpeg" || format === "webp")) {
1144
+ params.quality = opts.quality;
1145
+ }
1146
+ if (opts.clip !== undefined) {
1147
+ // CDP requires `scale` — default 1 if caller didn't set it.
1148
+ params.clip = { ...opts.clip, scale: opts.clip.scale ?? 1 };
1149
+ }
1150
+ if (opts.omitBackground === true) {
1151
+ params.omitBackground = true;
1152
+ }
1153
+
1154
+ // fullPage round-trip. We capture the layout metrics first, then size
1155
+ // the device viewport up to the content size, capture, then clear the
1156
+ // override. The `try/finally` is load-bearing — if `captureScreenshot`
1157
+ // throws (e.g. target detached mid-capture) we still need to restore
1158
+ // the viewport so subsequent calls don't see a frozen oversized layout.
1159
+ let restoreOverride = false;
1160
+ if (opts.fullPage === true && opts.clip === undefined) {
1161
+ const metrics = await this.send<{
1162
+ contentSize: { width: number; height: number };
1163
+ layoutViewport: { clientWidth: number; clientHeight: number };
1164
+ }>("Page.getLayoutMetrics");
1165
+ const width = Math.ceil(metrics.contentSize.width);
1166
+ const height = Math.ceil(metrics.contentSize.height);
1167
+ await this.send("Emulation.setDeviceMetricsOverride", {
1168
+ width,
1169
+ height,
1170
+ deviceScaleFactor: 0,
1171
+ mobile: false,
1172
+ });
1173
+ restoreOverride = true;
1174
+ params.captureBeyondViewport = true;
1175
+ }
1176
+
1177
+ let result: { data: string };
1178
+ try {
1179
+ result = await this.send<{ data: string }>("Page.captureScreenshot", params);
1180
+ } finally {
1181
+ if (restoreOverride) {
1182
+ // Always clear, even if capture threw. Best-effort: if the target is
1183
+ // gone the clear will fail and we swallow it — the page is unusable
1184
+ // anyway and the override dies with the target.
1185
+ try {
1186
+ await this.send("Emulation.clearDeviceMetricsOverride");
1187
+ } catch {
1188
+ // ignore
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ const encoding = opts.encoding ?? "binary";
1194
+ if (encoding === "base64") {
1195
+ return result.data;
1196
+ }
1197
+ // Decode base64 → bytes via Bun-native `Buffer.from`. The Buffer is a
1198
+ // Uint8Array subclass; we slice into a plain Uint8Array view backed by
1199
+ // the same memory so the public type is the standard one.
1200
+ const buf = Buffer.from(result.data, "base64");
1201
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
880
1202
  }
881
1203
 
882
1204
  // ---- internals --------------------------------------------------------------
@@ -886,6 +1208,52 @@ export class Page {
886
1208
  return this.router.send<T>(method, params, { sessionId: this.sessionId });
887
1209
  }
888
1210
 
1211
+ /**
1212
+ * Resolve the page's main-frame origin via `Runtime.callFunctionOn` against
1213
+ * the document objectId. Used by {@link grantAllPermissions} and the DOM
1214
+ * storage namespaces when the caller didn't pass an explicit `origin`.
1215
+ *
1216
+ * Throws with a precise diagnostic when the origin is opaque
1217
+ * (`about:blank` / `data:` URLs) — the consumers can't fall back to
1218
+ * "current page" because there's nothing meaningful to scope.
1219
+ */
1220
+ private async resolveOrigin(callerName: string): Promise<string> {
1221
+ const docId = await this.documentObjectId();
1222
+ const r = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
1223
+ objectId: docId,
1224
+ functionDeclaration:
1225
+ "function() { return (this.defaultView || window).location && (this.defaultView || window).location.origin || ''; }",
1226
+ returnByValue: true,
1227
+ });
1228
+ const v = r.result.value;
1229
+ if (typeof v !== "string" || v.length === 0 || v === "null") {
1230
+ throw new Error(
1231
+ `[mochi] page.${callerName}: page origin is opaque (likely about:blank). Pass { origin } explicitly.`,
1232
+ );
1233
+ }
1234
+ return v;
1235
+ }
1236
+
1237
+ /**
1238
+ * Module-private accessor used by {@link createDomStorage}. Mirrors the
1239
+ * cookie-jar plumbing pattern on Session — the factory lives in module
1240
+ * scope so its return type can be the public {@link DomStorage} interface
1241
+ * without leaking implementation onto the Page surface.
1242
+ *
1243
+ * @internal
1244
+ */
1245
+ _internalDomStoragePlumbing(): {
1246
+ send: <T>(method: string, params?: unknown) => Promise<T>;
1247
+ resolveOrigin: (caller: string) => Promise<string>;
1248
+ assertOpen: () => void;
1249
+ } {
1250
+ return {
1251
+ send: <T>(method: string, params?: unknown) => this.send<T>(method, params),
1252
+ resolveOrigin: (caller: string) => this.resolveOrigin(caller),
1253
+ assertOpen: () => this.assertOpen(),
1254
+ };
1255
+ }
1256
+
889
1257
  /** Subscribe to frame events to keep `currentUrl` and `mainFrameId` fresh. */
890
1258
  private subscribeFrameTopology(): void {
891
1259
  this.router.on("Page.frameNavigated", (params, sessionId) => {
@@ -1128,3 +1496,54 @@ function hash01(s: string): number {
1128
1496
  }
1129
1497
  return (h >>> 0) / 0x1_0000_0000;
1130
1498
  }
1499
+
1500
+ // ---- DOM storage factory ----------------------------------------
1501
+
1502
+ /**
1503
+ * Build the {@link DomStorage} returned by `Page.localStorage` /
1504
+ * `Page.sessionStorage`. Bound to one Page instance via
1505
+ * {@link Page._internalDomStoragePlumbing}. Module-private; the public surface
1506
+ * is the interface — instances are only created by the Page constructor.
1507
+ *
1508
+ * `isLocalStorage` flag picks the CDP storage backing:
1509
+ * - `true` → `localStorage` (the persistent per-origin store).
1510
+ * - `false` → `sessionStorage` (the per-tab transient store).
1511
+ *
1512
+ * @internal
1513
+ */
1514
+ function createDomStorage(page: Page, isLocalStorage: boolean): DomStorage {
1515
+ const { send, resolveOrigin, assertOpen } = page._internalDomStoragePlumbing();
1516
+ const callerName = isLocalStorage ? "localStorage" : "sessionStorage";
1517
+ return {
1518
+ async get(opts: DomStorageOptions = {}) {
1519
+ assertOpen();
1520
+ const securityOrigin = opts.origin ?? (await resolveOrigin(`${callerName}.get`));
1521
+ const result = await send<{ entries: Array<[string, string]> }>(
1522
+ "DOMStorage.getDOMStorageItems",
1523
+ { storageId: { securityOrigin, isLocalStorage } },
1524
+ );
1525
+ // CDP returns `[ [k, v], ... ]`. Collapse to a Record for ergonomics.
1526
+ const out: Record<string, string> = {};
1527
+ for (const entry of result.entries) {
1528
+ const k = entry[0];
1529
+ const v = entry[1];
1530
+ if (typeof k === "string" && typeof v === "string") out[k] = v;
1531
+ }
1532
+ return out;
1533
+ },
1534
+ async set(items: Record<string, string>, opts: DomStorageOptions = {}) {
1535
+ assertOpen();
1536
+ const securityOrigin = opts.origin ?? (await resolveOrigin(`${callerName}.set`));
1537
+ // CDP's `setDOMStorageItem` takes one key/value at a time. We fan out
1538
+ // sequentially so a partial failure (e.g. a too-large value) surfaces
1539
+ // with the offending key in the error frame.
1540
+ for (const [k, v] of Object.entries(items)) {
1541
+ await send("DOMStorage.setDOMStorageItem", {
1542
+ storageId: { securityOrigin, isLocalStorage },
1543
+ key: k,
1544
+ value: v,
1545
+ });
1546
+ }
1547
+ },
1548
+ };
1549
+ }
package/src/proc.ts CHANGED
@@ -15,7 +15,7 @@ import type { PipeReader, PipeWriter } from "./cdp/transport";
15
15
  /**
16
16
  * The chromium flags PLAN.md §8.6 mandates we always pass in PRODUCTION
17
17
  * (non-hermetic) mode. Trimmed against patchright's
18
- * `chromiumSwitchesPatch.ts:20-34` removal list (task 0256): every flag
18
+ * `chromiumSwitchesPatch.ts:20-34` removal list: every flag
19
19
  * here passes two tests — (a) it isn't a passive command-line bot-tell that
20
20
  * patchright explicitly drops, AND (b) we have a concrete production reason
21
21
  * to keep it (CDP transport, UI suppression that matters in headed mode,
@@ -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
- /** Run headless via Chromium's modern `--headless=new` flag. */
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
  /**
@@ -154,7 +181,7 @@ export interface SpawnConfig {
154
181
  * integers; otherwise the flag is omitted. Sourced from
155
182
  * `matrix.display.{width,height}` by `launch.ts` — the matrix is canonical.
156
183
  *
157
- * @see UDC `__init__.py:410-411`, UDC issue #2242, task 0252.
184
+ * @see UDC `__init__.py:410-411`, UDC issue #2242,
158
185
  */
159
186
  windowSize?: { width: number; height: number };
160
187
  /**
@@ -389,12 +416,28 @@ export function buildChromiumArgs(
389
416
  if (cfg.hermetic === true) {
390
417
  args.push(...HERMETIC_ONLY_CHROMIUM_FLAGS);
391
418
  }
392
- if (cfg.headless) {
393
- // Modern headless mode (matches stable Chrome behavior more closely than
394
- // legacy --headless). The `=new` is critical old `--headless` is
395
- // detectable.
419
+ // Headless dispatch.
420
+ //
421
+ // `headlessMode` 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
  }
@@ -402,7 +445,7 @@ export function buildChromiumArgs(
402
445
  // header so the network surface matches the JS-layer `navigator.language`
403
446
  // spoof (PLAN.md I-5). Pushed BEFORE `extraArgs` so a user-supplied
404
447
  // override in `args` can win on the command line if absolutely needed —
405
- // Chromium honors the last-occurrence on the line for `--lang`. Task 0251.
448
+ // Chromium honors the last-occurrence on the line for `--lang`.
406
449
  if (cfg.locale !== undefined && cfg.locale.length > 0) {
407
450
  args.push(`--lang=${cfg.locale}`);
408
451
  }
@@ -411,7 +454,7 @@ export function buildChromiumArgs(
411
454
  // Chromium's headless 800×600 default. The matrix is canonical: when
412
455
  // `display.{width,height}` is missing or non-finite we omit the flag
413
456
  // rather than fall back to a hardcoded value (a hardcoded value would
414
- // mismatch a profile that legitimately uses different dimensions). Task 0252.
457
+ // mismatch a profile that legitimately uses different dimensions).
415
458
  if (cfg.windowSize !== undefined) {
416
459
  const { width, height } = cfg.windowSize;
417
460
  if (
@@ -458,7 +501,6 @@ export function buildChromiumArgs(
458
501
  * surfaces only the raw stderr tail. Exported for unit tests so we can lock
459
502
  * the regexes against regressions without spawning Chromium.
460
503
  *
461
- * @see tasks/0259-linux-first-run-experience.md
462
504
  */
463
505
  export function diagnoseEarlyExitTail(tail: string): string {
464
506
  if (/running.*root.*without.*--no-sandbox|--no-sandbox.*required/i.test(tail)) {