@mochi.js/core 0.6.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/launch.ts CHANGED
@@ -92,7 +92,7 @@ export interface LaunchOptions {
92
92
  * string (looked up against `KNOWN_PROFILE_IDS`) or an inline `ProfileV1`
93
93
  * object.
94
94
  *
95
- * **Optional since task 0272** — when omitted, mochi auto-picks the
95
+ * **Optional** — when omitted, mochi auto-picks the
96
96
  * profile whose declared OS matches the host's `process.platform` /
97
97
  * `process.arch` pair via {@link defaultProfileForHost}:
98
98
  *
@@ -120,7 +120,7 @@ export interface LaunchOptions {
120
120
  * `false` (default in v0.1) runs headful. New code should prefer
121
121
  * {@link headlessMode}, which is more expressive AND env-aware.
122
122
  *
123
- * Resolution priority (task 0258):
123
+ * Resolution priority:
124
124
  *
125
125
  * 1. `headlessMode` if set.
126
126
  * 2. Else `headless: true → "new"`, `headless: false → "off"`.
@@ -130,7 +130,7 @@ export interface LaunchOptions {
130
130
  */
131
131
  headless?: boolean;
132
132
  /**
133
- * Headless dispatch mode (task 0258). One of:
133
+ * Headless dispatch mode. One of:
134
134
  *
135
135
  * - `"new"` — modern Chromium headless (`--headless=new`). Full
136
136
  * rendering, near-byte-identical to headful for
@@ -178,7 +178,7 @@ export interface LaunchOptions {
178
178
  * trivially fingerprinted as Chromium-for-Testing.
179
179
  *
180
180
  * Defaults to `false`. PLAN.md §12.1 (capture must run against bare
181
- * Chromium); task 0040.
181
+ * Chromium);
182
182
  */
183
183
  bypassInject?: boolean;
184
184
  /**
@@ -199,7 +199,7 @@ export interface LaunchOptions {
199
199
  *
200
200
  * Pairs with — but is independent of — {@link bypassInject}. Capture
201
201
  * flows set both `true`; harness conformance runs set `hermetic: true`
202
- * with full inject pipeline active. PLAN.md §8.6 + task 0256.
202
+ * with full inject pipeline active. PLAN.md §8.6 +
203
203
  */
204
204
  hermetic?: boolean;
205
205
  /**
@@ -235,13 +235,13 @@ export interface LaunchOptions {
235
235
  * - `"off"` — skip the probe entirely. Use in offline tests / when
236
236
  * the probe service is rate-limited.
237
237
  *
238
- * The probe is a single GET through wreq (using the matrix's
239
- * `wreqPreset`, so the geo service sees the same JA4/headers as user
240
- * traffic). 4-attempt cap, 2s per endpoint. Probe results are NOT
241
- * cached across sessions — proxy IPs rotate.
238
+ * The probe is a single GET through Chromium itself (Session.fetch via
239
+ * CDP `Network.loadNetworkResource`), so the geo service sees the same
240
+ * JA4 / headers as user traffic by definition. 4-attempt cap, 2s per
241
+ * endpoint. Probe results are NOT cached across sessions — proxy IPs
242
+ * rotate.
242
243
  *
243
244
  * @see PLAN.md §9 (relational consistency, IP/TZ/Locale axis)
244
- * @see tasks/0262-ip-tz-locale-exit-consistency.md
245
245
  */
246
246
  geoConsistency?: GeoConsistencyMode;
247
247
  }
@@ -255,8 +255,8 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
255
255
  const normalized = normalizeProxy(opts.proxy);
256
256
 
257
257
  // Resolve the `MatrixV1` BEFORE spawning so matrix-derived values flow
258
- // into both the `--lang` flag (task 0251) and `--window-size` flag
259
- // (task 0252). The matrix is otherwise read post-spawn for inject;
258
+ // into both the `--lang` flag and `--window-size` flag
259
+ //. The matrix is otherwise read post-spawn for inject;
260
260
  // deriving early is cheap (~µs, pure function) and lets us close the
261
261
  // I-5 leaks between Chromium's native network/OS-window state and the
262
262
  // JS-layer spoof.
@@ -275,7 +275,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
275
275
  if (profileSource.autoPicked) {
276
276
  // One info-level log line so users can see what mochi inferred without
277
277
  // calling `defaultProfileForHost()` themselves. Wording is pinned by
278
- // task 0272 — keep stable so docs + LLM-context blocks stay correct.
278
+ // — keep stable so docs + LLM-context blocks stay correct.
279
279
  // (Routed through `console.warn` to match the existing diagnostic
280
280
  // channel for `geoConsistency` / Linux-server inference; `console.info`
281
281
  // is gated by the workspace lint config — `noConsole` only allows
@@ -289,9 +289,10 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
289
289
 
290
290
  // Task 0262 — exit-IP / TZ / locale reconciliation.
291
291
  //
292
- // Probe the apparent exit IP through the configured proxy (using wreq
293
- // with the matrix's `wreqPreset` so the geo service sees the same JA4
294
- // / headers as user traffic). Then cross-reference against
292
+ // Probe the apparent exit IP through the configured proxy. Post-0.7
293
+ // the probe runs through Chromium itself (Session.fetch via CDP
294
+ // `Network.loadNetworkResource`), so the geo service sees the same
295
+ // JA4 / headers as user traffic by definition. Cross-reference against
295
296
  // `(matrix.timezone, matrix.locale)` and apply `geoConsistency`. The
296
297
  // adjusted matrix flows into BOTH `spawnChromium` (so `--lang` reflects
297
298
  // any override) AND `Session` (so inject + the CDP `Emulation.set
@@ -303,7 +304,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
303
304
  let adjustedMatrix = matrix;
304
305
  if (geoMode !== "off") {
305
306
  const geo = await probeExitGeo({
306
- ...(normalized?.netProxy !== undefined ? { proxy: normalized.netProxy } : {}),
307
+ ...(normalized?.proxy !== undefined ? { proxy: normalized.proxy } : {}),
307
308
  matrix,
308
309
  });
309
310
  // Strict mode throws GeoMismatchError on real mismatch; let it
@@ -359,13 +360,13 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
359
360
  // multi-locale list still flows through `matrix.languages` to the
360
361
  // inject layer's `navigator.languages` spoof; Chromium derives the
361
362
  // q-weighted `Accept-Language` value from the single `--lang` primary
362
- // automatically. Task 0251.
363
+ // automatically.
363
364
  locale: adjustedMatrix.locale,
364
365
  // Pin OS-level outer window from the matrix's display geometry so
365
366
  // `window.outerWidth/outerHeight` (which reads from the OS window,
366
367
  // NOT the JS-spoofed `screen.*`) matches the spoof. Closes the
367
368
  // `fingerprint-scan.com` 800×600 leak under `--headless=new`.
368
- // UDC fixes the same issue at `__init__.py:410-411`. Task 0252.
369
+ // UDC fixes the same issue at `__init__.py:410-411`.
369
370
  //
370
371
  // (`adjustedMatrix.display` === `matrix.display` since geo reconcile
371
372
  // only touches timezone/locale/languages — but we use the adjusted
@@ -395,10 +396,11 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
395
396
  seed: opts.seed,
396
397
  ...(opts.timeout !== undefined ? { defaultTimeoutMs: opts.timeout } : {}),
397
398
  ...(opts.bypassInject === true ? { bypassInject: true } : {}),
398
- // Forward the same proxy (with auth, if any) to the net FFI so
399
- // out-of-band Session.fetch traffic shares the apparent egress.
400
- // `@mochi.js/net` (wreq) accepts the full `user:pass@host` URL form.
401
- ...(normalized !== undefined ? { netProxy: normalized.netProxy } : {}),
399
+ // Proxy auth is the only piece that needs explicit Session-side
400
+ // wiring (the `--proxy-server` flag is already on Chromium's command
401
+ // line above). Out-of-band `Session.fetch` traffic rides Chromium's
402
+ // network stack post-0.7, so it inherits the `--proxy-server` egress
403
+ // automatically — no per-call proxy URL needed.
402
404
  ...(normalized?.auth !== undefined ? { proxyAuth: normalized.auth } : {}),
403
405
  ...(opts.challenges !== undefined ? { challenges: opts.challenges } : {}),
404
406
  });
@@ -417,16 +419,15 @@ export const mochi = {
417
419
  * Inspect what mochi would infer about the current process environment for
418
420
  * Linux-server detection (drives `headlessMode` defaulting). Pure read of
419
421
  * `process.platform`, `process.env.DISPLAY`, `process.env.WAYLAND_DISPLAY`,
420
- * `process.getuid?.()`, and the container probe paths. Task 0258.
422
+ * `process.getuid?.()`, and the container probe paths.
421
423
  */
422
424
  detectLinuxServerEnv: probeLinuxServerEnv,
423
425
  /**
424
426
  * Inspect which profile id `mochi.launch` would auto-pick on the current
425
427
  * host when `profile` is omitted. Pure read of `process.platform` /
426
428
  * `process.arch`. Returns `null` on unsupported hosts — the launcher
427
- * throws on that path with a list of explicit profile IDs. Task 0272.
429
+ * throws on that path with a list of explicit profile IDs.
428
430
  *
429
- * @see tasks/0271-the-linux-os-thesis.md — the strategic thesis
430
431
  * @see https://mochijs.com/docs/concepts/stealth-philosophy
431
432
  */
432
433
  defaultProfileForHost,
@@ -463,9 +464,10 @@ export function resolveHeadlessMode(
463
464
  * Reconcile the two `LaunchOptions.proxy` shapes (URL string and
464
465
  * `ProxyConfig` record) into a single normalized record carrying:
465
466
  * - `server`: auth-stripped URL safe to feed `--proxy-server=`.
466
- * - `netProxy`: the URL handed to the network FFI. Preserves credentials
467
- * (wreq accepts `user:pass@host` inline) so out-of-band fetches
468
- * authenticate against the same proxy.
467
+ * - `proxy`: the auth-stripped URL forwarded to the geo-probe so it
468
+ * can record the egress on diagnostics. (Kept for API parity even
469
+ * though the probe now rides Session.fetch + Chromium's network
470
+ * stack — i.e. picks up `--proxy-server` automatically.)
469
471
  * - `auth`: parsed credentials for the CDP auth handler. Undefined when
470
472
  * no creds were supplied.
471
473
  *
@@ -474,7 +476,7 @@ export function resolveHeadlessMode(
474
476
  function normalizeProxy(p: LaunchOptions["proxy"]):
475
477
  | {
476
478
  server: string;
477
- netProxy: string;
479
+ proxy: string;
478
480
  auth?: { username: string; password: string };
479
481
  }
480
482
  | undefined {
@@ -484,7 +486,7 @@ function normalizeProxy(p: LaunchOptions["proxy"]):
484
486
  const parsed = parseProxyUrl(p);
485
487
  return {
486
488
  server: parsed.server,
487
- netProxy: p,
489
+ proxy: parsed.server,
488
490
  ...(parsed.auth !== undefined ? { auth: parsed.auth } : {}),
489
491
  };
490
492
  }
@@ -493,31 +495,13 @@ function normalizeProxy(p: LaunchOptions["proxy"]):
493
495
  const parsed = parseProxyUrl(p.server);
494
496
  const auth =
495
497
  p.username !== undefined ? { username: p.username, password: p.password ?? "" } : parsed.auth;
496
- // Reconstruct the netProxy URL preserving any explicit auth (wreq path).
497
- const netProxy = auth !== undefined ? injectAuth(parsed.server, auth) : parsed.server;
498
498
  return {
499
499
  server: parsed.server,
500
- netProxy,
500
+ proxy: parsed.server,
501
501
  ...(auth !== undefined ? { auth } : {}),
502
502
  };
503
503
  }
504
504
 
505
- /**
506
- * Inject `username:password@` into a server URL, percent-encoding both
507
- * components so reserved characters round-trip cleanly through wreq's URL
508
- * parser.
509
- */
510
- function injectAuth(server: string, auth: { username: string; password: string }): string {
511
- const u = encodeURIComponent(auth.username);
512
- const p = encodeURIComponent(auth.password);
513
- // server is `<protocol>://<host>:<port>` (per parseProxyUrl).
514
- const idx = server.indexOf("://");
515
- if (idx < 0) return server;
516
- const head = server.slice(0, idx + 3);
517
- const tail = server.slice(idx + 3);
518
- return `${head}${u}:${p}@${tail}`;
519
- }
520
-
521
505
  /**
522
506
  * Resolve `LaunchOptions.profile` into a concrete `ProfileV1` plus the
523
507
  * meta-flag the launcher needs to decide whether to log the auto-pick
@@ -550,7 +534,7 @@ function resolveProfileSource(profile: ProfileId | ProfileV1 | undefined): {
550
534
  autoPicked: false,
551
535
  };
552
536
  }
553
- // Auto-pick branch — task 0272.
537
+ // Auto-pick branch —
554
538
  const picked = defaultProfileForHost();
555
539
  if (picked === null) {
556
540
  throw new Error(unsupportedHostMessage(process.platform, process.arch));
@@ -598,7 +582,9 @@ function synthesizePlaceholderProfile(profile: ProfileId): ProfileV1 {
598
582
  locale: "en-US",
599
583
  languages: ["en-US", "en"],
600
584
  behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
601
- wreqPreset: "chrome_131_linux",
585
+ // Deprecated — kept for one release for migration; runtime no longer
586
+ // reads the field. Drops in 0.8.
587
+ wreqPreset: "chrome_148_linux",
602
588
  userAgent:
603
589
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
604
590
  uaCh: {},
@@ -17,7 +17,6 @@
17
17
  * which is fine for a v0.2 surface.
18
18
  *
19
19
  * @see PLAN.md §8.2 / §8.3
20
- * @see tasks/0253-closed-shadow-piercing-locator.md
21
20
  */
22
21
 
23
22
  import type { MessageRouter } from "../cdp/router";
@@ -33,7 +33,6 @@
33
33
  * brief — a per-page cache layer is a v0.3+ concern).
34
34
  *
35
35
  * @see PLAN.md §8.2 — `DOM.getDocument` / `DOM.resolveNode` are not forbidden
36
- * @see tasks/0253-closed-shadow-piercing-locator.md
37
36
  */
38
37
 
39
38
  import type { PierceDomNode } from "../cdp/types";
@@ -31,7 +31,6 @@
31
31
  * Throws `SelectorParseError` on syntactically invalid input. The matcher
32
32
  * itself never throws — unsupported nodes just don't match.
33
33
  *
34
- * @see tasks/0253-closed-shadow-piercing-locator.md
35
34
  * @see PLAN.md §8.2 (forbidden CDP — neither `DOM.getDocument` nor
36
35
  * `DOM.resolveNode` is forbidden; both fine).
37
36
  */
package/src/page.ts CHANGED
@@ -202,7 +202,7 @@ export interface ScreenshotOptions {
202
202
  encoding?: "binary" | "base64";
203
203
  }
204
204
 
205
- // ---- DX cluster: DOM storage + permissions (task 0257) ---------------------
205
+ // ---- DX cluster: DOM storage + permissions ---------------------
206
206
 
207
207
  /**
208
208
  * Options for {@link Page.localStorage} / {@link Page.sessionStorage}
@@ -252,43 +252,61 @@ export interface DomStorage {
252
252
  /**
253
253
  * Every browser-level permission descriptor `Browser.grantPermissions` accepts.
254
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.
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`.
259
268
  *
260
269
  * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser/#type-PermissionType
261
270
  */
262
271
  export const ALL_BROWSER_PERMISSIONS = [
263
- "accessibilityEvents",
272
+ "ar",
264
273
  "audioCapture",
265
- "backgroundSync",
274
+ "automaticFullscreen",
266
275
  "backgroundFetch",
267
- "captureHandle",
276
+ "backgroundSync",
277
+ "cameraPanTiltZoom",
278
+ "capturedSurfaceControl",
268
279
  "clipboardReadWrite",
269
280
  "clipboardSanitizedWrite",
270
281
  "displayCapture",
271
282
  "durableStorage",
272
- "flash",
273
283
  "geolocation",
284
+ "handTracking",
274
285
  "idleDetection",
286
+ "keyboardLock",
275
287
  "localFonts",
288
+ "localNetwork",
289
+ "localNetworkAccess",
290
+ "loopbackNetwork",
276
291
  "midi",
277
292
  "midiSysex",
278
293
  "nfc",
279
294
  "notifications",
280
295
  "paymentHandler",
281
296
  "periodicBackgroundSync",
297
+ "pointerLock",
282
298
  "protectedMediaIdentifier",
283
299
  "sensors",
284
- "storageAccess",
300
+ "smartCard",
285
301
  "speakerSelection",
302
+ "storageAccess",
286
303
  "topLevelStorageAccess",
287
304
  "videoCapture",
288
- "videoCapturePanTiltZoom",
305
+ "vr",
289
306
  "wakeLockScreen",
290
307
  "wakeLockSystem",
291
308
  "webAppInstallation",
309
+ "webPrinting",
292
310
  "windowManagement",
293
311
  ] as const;
294
312
 
@@ -1030,13 +1048,12 @@ export class Page {
1030
1048
  * Supported selectors (see `selector.ts`): tag / id / class / attribute /
1031
1049
  * descendant combinator / comma-separated lists. **Not** supported:
1032
1050
  * `>`/`+`/`~` combinators, `:pseudo-classes`, `::pseudo-elements`, XPath.
1033
- * 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
1034
1052
  * needs it (Turnstile detection only needs CSS).
1035
1053
  *
1036
1054
  * Performance: O(N) in DOM size per call. Acceptable for v0.2; a per-page
1037
1055
  * cache layer is a v0.3+ concern (also called out in 0253).
1038
1056
  *
1039
- * @see tasks/0253-closed-shadow-piercing-locator.md
1040
1057
  * @see PLAN.md §8.2 (`DOM.getDocument` and `DOM.resolveNode` are not on the
1041
1058
  * forbidden list — both fine to use here).
1042
1059
  */
@@ -1480,7 +1497,7 @@ function hash01(s: string): number {
1480
1497
  return (h >>> 0) / 0x1_0000_0000;
1481
1498
  }
1482
1499
 
1483
- // ---- DOM storage factory (task 0257) ----------------------------------------
1500
+ // ---- DOM storage factory ----------------------------------------
1484
1501
 
1485
1502
  /**
1486
1503
  * Build the {@link DomStorage} returned by `Page.localStorage` /
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,
@@ -181,7 +181,7 @@ export interface SpawnConfig {
181
181
  * integers; otherwise the flag is omitted. Sourced from
182
182
  * `matrix.display.{width,height}` by `launch.ts` — the matrix is canonical.
183
183
  *
184
- * @see UDC `__init__.py:410-411`, UDC issue #2242, task 0252.
184
+ * @see UDC `__init__.py:410-411`, UDC issue #2242,
185
185
  */
186
186
  windowSize?: { width: number; height: number };
187
187
  /**
@@ -418,7 +418,7 @@ export function buildChromiumArgs(
418
418
  }
419
419
  // Headless dispatch.
420
420
  //
421
- // `headlessMode` (task 0258) supersedes the legacy `headless: boolean` knob
421
+ // `headlessMode` supersedes the legacy `headless: boolean` knob
422
422
  // when both are set. When `headlessMode` is unset, fall back to the v0.1
423
423
  // mapping (`headless: true → "new"`, `false → "off"`). The launcher in
424
424
  // `launch.ts` is responsible for the env-aware default ("new" on Linux
@@ -445,7 +445,7 @@ export function buildChromiumArgs(
445
445
  // header so the network surface matches the JS-layer `navigator.language`
446
446
  // spoof (PLAN.md I-5). Pushed BEFORE `extraArgs` so a user-supplied
447
447
  // override in `args` can win on the command line if absolutely needed —
448
- // Chromium honors the last-occurrence on the line for `--lang`. Task 0251.
448
+ // Chromium honors the last-occurrence on the line for `--lang`.
449
449
  if (cfg.locale !== undefined && cfg.locale.length > 0) {
450
450
  args.push(`--lang=${cfg.locale}`);
451
451
  }
@@ -454,7 +454,7 @@ export function buildChromiumArgs(
454
454
  // Chromium's headless 800×600 default. The matrix is canonical: when
455
455
  // `display.{width,height}` is missing or non-finite we omit the flag
456
456
  // rather than fall back to a hardcoded value (a hardcoded value would
457
- // mismatch a profile that legitimately uses different dimensions). Task 0252.
457
+ // mismatch a profile that legitimately uses different dimensions).
458
458
  if (cfg.windowSize !== undefined) {
459
459
  const { width, height } = cfg.windowSize;
460
460
  if (
@@ -501,7 +501,6 @@ export function buildChromiumArgs(
501
501
  * surfaces only the raw stderr tail. Exported for unit tests so we can lock
502
502
  * the regexes against regressions without spawning Chromium.
503
503
  *
504
- * @see tasks/0259-linux-first-run-experience.md
505
504
  */
506
505
  export function diagnoseEarlyExitTail(tail: string): string {
507
506
  if (/running.*root.*without.*--no-sandbox|--no-sandbox.*required/i.test(tail)) {
package/src/proxy-auth.ts CHANGED
@@ -39,8 +39,6 @@
39
39
  * handler covers both.
40
40
  *
41
41
  * @see PLAN.md §8.2 / §10
42
- * @see tasks/0160-proxy-auth-and-ci-fix.md
43
- * @see tasks/0266-fetch-fulfill-init-script.md
44
42
  */
45
43
 
46
44
  import { installInitInjector } from "./cdp/init-injector";
@@ -148,7 +146,7 @@ export interface ProxyAuthHandle {
148
146
  * shim — delegates to {@link installInitInjector} with `payloadCode: null`
149
147
  * so the proxy-auth-only call path still works for any out-of-tree caller.
150
148
  *
151
- * The Session no longer uses this directly (task 0266); proxy auth and
149
+ * The Session no longer uses this directly; proxy auth and
152
150
  * init-script delivery share a single `Fetch.enable` owner.
153
151
  *
154
152
  * Behavior (unchanged contract):