@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/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,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
- screenshot(_opts?: unknown): Promise<Uint8Array> {
879
- return Promise.reject(new NotImplementedError("page.screenshot"));
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
- /** 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
  /**
@@ -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` (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
  }