humanish 0.75.0 → 0.77.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 CHANGED
@@ -53,12 +53,14 @@ npx humanish run first-run # a study with no keys and no spend — about a mi
53
53
  npx humanish run try-live # a REAL study on a hosted desktop, capped at $2
54
54
  ```
55
55
 
56
- **What it finds, measured (2026-09-01, receipts in `bench/` and `docs/goals/`):** 29 of 30 planted
57
- defects over two benchmark runs on an app we wrote, none invented on the clean arm; on two apps we
58
- did not write, 16 of 18 distinct findings confirmed against the source and 0 invented, across 14
59
- participants; and a modal that stopped 2 of 3 keyboard-first participants, was reported by
60
- all 3, and was never mentioned by 3 mouse-driving newcomers. Each file states
61
- its own caveats.
56
+ **What it finds, measured (2026-09-01 to 09-04, receipts in `bench/` and `docs/goals/`):** 58 of
57
+ 60 planted defects over four benchmark runs on an app we wrote, none invented in 15 clean runs; on
58
+ two apps we did not write, 16 of 18 distinct findings confirmed against the source and 0 invented,
59
+ across 14 participants; a drawDB modal reported by 5 of 5 keyboard-first participants (3 stopped
60
+ there) and never mentioned by 5 mouse-driving newcomers; a TodoMVC rename that blocked 6 of 6
61
+ keyboard-first participants and none of the others; and, on a phone-sized desktop, a drawDB
62
+ relationship drag intercepted by a popover that both phone participants hit (one stopped) and no
63
+ desktop participant reported. Each file states its own caveats.
62
64
 
63
65
  `try-live` studies a real public app so that your first live run works without
64
66
  configuring anything; point its `subject` at your own app once you have seen it
@@ -343,7 +345,8 @@ persisted evidence: screenshots, events, terminal tails). See
343
345
  | `humanish runs` | List local runs and latest pointers. |
344
346
  | `humanish export` | One self-contained `.html` of a run's Observer with screenshots inlined; verify and the share_ready gate run inside, `--local-only` watermarks a raw-screenshot bundle. |
345
347
  | `humanish stats` | Cost, outcome, and duration roll-ups across run history; `--lab`, `--since`. Estimates stay labelled; unknown costs count as unknown. |
346
- | `humanish feedback issue` | Print a public-safe GitHub issue draft without API mutation. |
348
+ | `humanish feedback list` | List a run's draft state and every feedback candidate (one per participant finding), with the ids `--candidate` takes. |
349
+ | `humanish feedback issue` | Print a public-safe GitHub issue draft without API mutation. `--candidate <id>` chooses which finding; default is the first. |
347
350
  | `humanish lab run oss` | Repo-maintainer contract example: dry-run Observer-of-Observers for authorized repo selections. |
348
351
  | `humanish lab run oss-smoke` | Repo-maintainer dogfood example: disposable clone smoke test against public OSS repos. |
349
352
 
@@ -463,12 +466,28 @@ viewport is smaller because browser chrome occupies space; Chromium-family live
463
466
  measure it through CDP and record it separately from requested/verified screen geometry.
464
467
  Browsers without that measurement seam omit the viewport rather than guessing. A
465
468
  site's width-based responsive CSS still fires, and the model is *told* its device in the
466
- prompt, matching how those sims run organic mobile lanes. There is no touch input, the
467
- device-pixel-ratio isn't rendered, and the user-agent stays desktop on this route; true
468
- touch/DPR/UA emulation arrives with the deterministic CDP actor. Device is run-wide
469
- today; per-*persona* device (N personas × devices) lands with fan-out.
469
+ prompt, matching how those sims run organic mobile lanes. Without the block below there is no
470
+ touch input, the device-pixel-ratio isn't rendered, and the user-agent stays desktop on this
471
+ route. Device is run-wide today; per-*persona* device (N personas × devices) lands with fan-out.
470
472
  `execution.desktop.resolution` is a raw escape hatch that overrides the preset.
471
473
 
474
+ **Mobile emulation.** `execution.desktop.fidelity: { mobileEmulation: true }` turns every hosted
475
+ Chrome/Chromium computer-use lane on a mobile preset (`mobile`, `small-mobile`, `narrow-mobile`)
476
+ into a mobile-emulated browser before the participant arrives, and leaves desktop, tablet and
477
+ wide lanes in the same run untouched: the lane's preset width/height become the CSS viewport (414 px for `mobile`, where the
478
+ X screen itself cannot go below 500), the preset's device pixel ratio applies (`deviceScaleFactor`
479
+ overrides it), touch events are on (`touch: false` turns them off) and the browser presents a
480
+ mobile user agent (`userAgent` replaces the default iPhone Safari string). The run bundle records
481
+ `desktopGeometry.fidelity` with `tier: mobile-emulated`, the request, the CDP methods applied,
482
+ and `resolved`: what the page itself reported afterwards (`navigator.userAgent`,
483
+ `devicePixelRatio`, `innerWidth`, `maxTouchPoints`, coarse pointer). A page without a viewport
484
+ meta lays out at 980 px, as it would on a phone, and the bundle says so. Firefox cannot be
485
+ emulated, so the lane fails closed instead of shipping a desktop run labelled mobile. The
486
+ viewport and DPR override cover the launch tab (the user agent and touch flags are browser-wide);
487
+ if an observation reads a tab the participant opened later, the lane records one warning saying
488
+ so, because that tab laid out at the window width. A bundle without a `fidelity` block is a
489
+ responsive-viewport study whatever its preset is called.
490
+
472
491
  **Desktop browser choice.** Hosted computer-use lanes and shared-world actor seats use the
473
492
  route's historical opener unless you set `execution.desktop.browser` to `chrome`, `chromium`,
474
493
  or `firefox`. A concrete value means "launch this browser or fail"; it never silently
@@ -18,8 +18,36 @@ export interface ChromeCdpProbeArgs {
18
18
  * funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
19
19
  */
20
20
  prefer?: ChromeCdpPagePreference;
21
- /** "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution only. */
22
- mode: "state" | "geometry" | "port";
21
+ /**
22
+ * "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution
23
+ * only. "emulate": apply mobile emulation (#221) to the selected page and exit (the overrides that
24
+ * are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold": the same over a browser-level
25
+ * socket, then stay attached until killed (how a lane keeps them for its whole life) and attach
26
+ * to every page target Chrome opens later, sending it the same overrides and a reload the moment
27
+ * it exists, never pausing it (#623). "fidelity": read
28
+ * back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
29
+ */
30
+ mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
31
+ /** For "emulate": what to apply. */
32
+ emulation?: ChromeMobileEmulationRequest;
33
+ }
34
+ /** Mobile emulation request (#221): the CDP Emulation domain applied to one page target. */
35
+ export interface ChromeMobileEmulationRequest {
36
+ width: number;
37
+ height: number;
38
+ deviceScaleFactor: number;
39
+ touch: boolean;
40
+ userAgent: string;
41
+ platform?: string;
42
+ }
43
+ /** What the page reports after emulation: the proof, never copied from the request. */
44
+ export interface ChromeFidelityRead {
45
+ userAgent: string;
46
+ devicePixelRatio: number;
47
+ innerWidth: number;
48
+ innerHeight: number;
49
+ maxTouchPoints: number;
50
+ coarsePointer: boolean;
23
51
  }
24
52
  /** The probe's stdout, before the caller narrows it. */
25
53
  export interface ChromeCdpProbeResult {
@@ -41,6 +69,10 @@ export interface ChromeCdpProbeResult {
41
69
  height: number;
42
70
  deviceScaleFactor: number;
43
71
  };
72
+ /** "emulate": the CDP methods that returned without error, in order. */
73
+ applied?: string[];
74
+ /** "fidelity": the read-back. */
75
+ fidelity?: ChromeFidelityRead;
44
76
  }
45
77
  /**
46
78
  * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
@@ -28,7 +28,7 @@
28
28
  * a loopback read.
29
29
  */
30
30
  export const CHROME_CDP_PROBE_PY = String.raw `
31
- import base64, json, os, re, socket, struct, sys, urllib.request
31
+ import base64, json, os, re, socket, struct, sys, time, urllib.request
32
32
 
33
33
  def resolve_port(args):
34
34
  port = args.get("cdpPort")
@@ -73,35 +73,46 @@ def select_page(pages, args):
73
73
  return match
74
74
  return http_pages[0] if len(http_pages) == 1 else None
75
75
 
76
- def evaluate(ws_url, expression, timeout=1.5):
77
- match = re.match(r"^ws://([^/:]+):(\d+)(/.*)$", str(ws_url or ""))
78
- if not match:
79
- return None
80
- host, port, path = match.group(1), int(match.group(2)), match.group(3)
81
- sock = None
82
- try:
83
- sock = socket.create_connection((host, port), timeout=timeout)
84
- sock.settimeout(timeout)
76
+ class Ws:
77
+ """One DevTools WebSocket: hand-rolled client frames, JSON in and out; events that arrive while
78
+ a reply is awaited are kept in .events for the caller."""
79
+ def __init__(self, ws_url, timeout=1.5):
80
+ match = re.match(r"^ws://([^/:]+):(\d+)(/.*)$", str(ws_url or ""))
81
+ if not match:
82
+ raise ValueError("not a ws url")
83
+ host, port, path = match.group(1), int(match.group(2)), match.group(3)
84
+ self.sock = socket.create_connection((host, port), timeout=timeout)
85
+ self.sock.settimeout(timeout)
85
86
  key = base64.b64encode(os.urandom(16)).decode("ascii")
86
- handshake = (
87
+ self.sock.sendall((
87
88
  "GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
88
89
  "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, host, port, key)
89
- )
90
- sock.sendall(handshake.encode("ascii"))
90
+ ).encode("ascii"))
91
91
  buffer = b""
92
92
  while b"\r\n\r\n" not in buffer:
93
- chunk = sock.recv(4096)
93
+ chunk = self.sock.recv(4096)
94
94
  if not chunk:
95
- return None
95
+ raise EOFError("closed during the handshake")
96
96
  buffer += chunk
97
- head, buffer = buffer.split(b"\r\n\r\n", 1)
97
+ head, self.buffer = buffer.split(b"\r\n\r\n", 1)
98
98
  if b" 101 " not in head.split(b"\r\n", 1)[0]:
99
- return None
100
- payload = json.dumps({
101
- "id": 1,
102
- "method": "Runtime.evaluate",
103
- "params": {"returnByValue": True, "expression": expression},
104
- }).encode("utf-8")
99
+ raise EOFError("handshake refused")
100
+ self.next_id = 0
101
+ self.events = []
102
+
103
+ def need(self, count):
104
+ while len(self.buffer) < count:
105
+ chunk = self.sock.recv(65536)
106
+ if not chunk:
107
+ raise EOFError("socket closed")
108
+ self.buffer += chunk
109
+
110
+ def send(self, method, params=None, session_id=None):
111
+ self.next_id += 1
112
+ message = {"id": self.next_id, "method": method, "params": params or {}}
113
+ if session_id:
114
+ message["sessionId"] = session_id
115
+ payload = json.dumps(message).encode("utf-8")
105
116
  mask = os.urandom(4)
106
117
  frame = bytearray([0x81])
107
118
  size = len(payload)
@@ -114,36 +125,30 @@ def evaluate(ws_url, expression, timeout=1.5):
114
125
  frame.append(0x80 | 127)
115
126
  frame += struct.pack(">Q", size)
116
127
  frame += mask + bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
117
- sock.sendall(bytes(frame))
118
-
119
- state = {"buffer": buffer}
120
-
121
- def need(count):
122
- while len(state["buffer"]) < count:
123
- chunk = sock.recv(65536)
124
- if not chunk:
125
- raise EOFError("socket closed")
126
- state["buffer"] += chunk
128
+ self.sock.sendall(bytes(frame))
129
+ return self.next_id
127
130
 
131
+ def recv(self):
132
+ """The next JSON message (a reply or an event), or None once the socket closes."""
128
133
  message = b""
129
134
  while True:
130
- need(2)
131
- first, second = state["buffer"][0], state["buffer"][1]
135
+ self.need(2)
136
+ first, second = self.buffer[0], self.buffer[1]
132
137
  fin, opcode = first & 0x80, first & 0x0F
133
138
  masked, length, offset = second & 0x80, second & 0x7F, 2
134
139
  if length == 126:
135
- need(4)
136
- length, offset = struct.unpack(">H", state["buffer"][2:4])[0], 4
140
+ self.need(4)
141
+ length, offset = struct.unpack(">H", self.buffer[2:4])[0], 4
137
142
  elif length == 127:
138
- need(10)
139
- length, offset = struct.unpack(">Q", state["buffer"][2:10])[0], 10
143
+ self.need(10)
144
+ length, offset = struct.unpack(">Q", self.buffer[2:10])[0], 10
140
145
  frame_mask = b""
141
146
  if masked:
142
- need(offset + 4)
143
- frame_mask, offset = state["buffer"][offset:offset + 4], offset + 4
144
- need(offset + length)
145
- data = state["buffer"][offset:offset + length]
146
- state["buffer"] = state["buffer"][offset + length:]
147
+ self.need(offset + 4)
148
+ frame_mask, offset = self.buffer[offset:offset + 4], offset + 4
149
+ self.need(offset + length)
150
+ data = self.buffer[offset:offset + length]
151
+ self.buffer = self.buffer[offset + length:]
147
152
  if masked:
148
153
  data = bytes(byte ^ frame_mask[index % 4] for index, byte in enumerate(data))
149
154
  if opcode == 8:
@@ -157,18 +162,47 @@ def evaluate(ws_url, expression, timeout=1.5):
157
162
  except Exception:
158
163
  return None
159
164
  message = b""
160
- if isinstance(reply, dict) and reply.get("id") == 1:
161
- result = reply.get("result") or {}
162
- inner = result.get("result") if isinstance(result, dict) else None
163
- return inner.get("value") if isinstance(inner, dict) else None
165
+ if isinstance(reply, dict):
166
+ return reply
167
+
168
+ def call(self, method, params=None, session_id=None):
169
+ """Send one command and wait for its reply; events seen on the way are queued."""
170
+ wanted = self.send(method, params, session_id)
171
+ while True:
172
+ reply = self.recv()
173
+ if reply is None:
174
+ return None
175
+ if reply.get("id") == wanted:
176
+ return reply
177
+ if "method" in reply:
178
+ self.events.append(reply)
179
+
180
+ def close(self):
181
+ try:
182
+ self.sock.close()
183
+ except Exception:
184
+ pass
185
+
186
+ def ws_session(ws_url, messages, timeout=1.5):
187
+ """Send CDP messages over one page socket, in order, and return their replies (None on failure)."""
188
+ try:
189
+ ws = Ws(ws_url, timeout)
190
+ except Exception:
191
+ return None
192
+ try:
193
+ return [ws.call(message["method"], message.get("params")) for message in messages]
164
194
  except Exception:
165
195
  return None
166
196
  finally:
167
- if sock is not None:
168
- try:
169
- sock.close()
170
- except Exception:
171
- pass
197
+ ws.close()
198
+
199
+ def evaluate(ws_url, expression, timeout=1.5):
200
+ replies = ws_session(ws_url, [{"method": "Runtime.evaluate", "params": {"returnByValue": True, "expression": expression}}], timeout)
201
+ if not replies or not isinstance(replies[0], dict):
202
+ return None
203
+ result = replies[0].get("result") or {}
204
+ inner = result.get("result") if isinstance(result, dict) else None
205
+ return inner.get("value") if isinstance(inner, dict) else None
172
206
 
173
207
  STATE_EXPRESSION = (
174
208
  "({ url: location.href, title: document.title, "
@@ -179,6 +213,162 @@ GEOMETRY_EXPRESSION = (
179
213
  "({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, "
180
214
  "viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })"
181
215
  )
216
+ FIDELITY_EXPRESSION = (
217
+ "({ userAgent: navigator.userAgent, devicePixelRatio: window.devicePixelRatio, "
218
+ "innerWidth: window.innerWidth, innerHeight: window.innerHeight, "
219
+ "maxTouchPoints: navigator.maxTouchPoints || 0, "
220
+ "coarsePointer: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) })"
221
+ )
222
+
223
+ def emulation_messages(request, reload):
224
+ """The Emulation domain for one page session; reload only where scripts already ran at load."""
225
+ width = int(request.get("width") or 0)
226
+ height = int(request.get("height") or 0)
227
+ scale = float(request.get("deviceScaleFactor") or 1)
228
+ user_agent = str(request.get("userAgent") or "")
229
+ platform = str(request.get("platform") or "")
230
+ messages = [
231
+ {"method": "Emulation.setDeviceMetricsOverride", "params": {
232
+ "width": width, "height": height, "deviceScaleFactor": scale, "mobile": True,
233
+ "screenWidth": width, "screenHeight": height}},
234
+ ]
235
+ if request.get("touch"):
236
+ messages.append({"method": "Emulation.setTouchEmulationEnabled", "params": {"enabled": True, "maxTouchPoints": 5}})
237
+ messages.append({"method": "Emulation.setEmitTouchEventsForMouse", "params": {"enabled": True, "configuration": "mobile"}})
238
+ if user_agent:
239
+ params = {"userAgent": user_agent}
240
+ if platform:
241
+ params["platform"] = platform
242
+ messages.append({"method": "Emulation.setUserAgentOverride", "params": params})
243
+ if reload:
244
+ messages.append({"method": "Page.reload", "params": {}})
245
+ return messages
246
+
247
+ def applied_of(messages, replies):
248
+ applied = []
249
+ for message, reply in zip(messages, replies):
250
+ if isinstance(reply, dict) and "error" not in reply:
251
+ applied.append(message["method"])
252
+ else:
253
+ detail = (reply or {}).get("error", {}).get("message") if isinstance(reply, dict) else "no reply"
254
+ return applied, "%s failed: %s" % (message["method"], detail)
255
+ return applied, None
256
+
257
+ def emulate(ws_url, request):
258
+ """One-shot: apply the Emulation domain to the page and reload it. The session-scoped overrides
259
+ lapse when this socket closes; the lane uses hold() instead."""
260
+ messages = emulation_messages(request, reload=True)
261
+ replies = ws_session(ws_url, messages, timeout=5)
262
+ if replies is None:
263
+ return None, "the page socket could not be opened"
264
+ return applied_of(messages, replies)
265
+
266
+ def apply_over(ws, request, session_id, reload):
267
+ messages = emulation_messages(request, reload)
268
+ return applied_of(messages, [ws.call(m["method"], m.get("params"), session_id) for m in messages])
269
+
270
+ def browser_ws_url(port):
271
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
272
+ with opener.open("http://127.0.0.1:%d/json/version" % port, timeout=2) as response:
273
+ info = json.loads(response.read().decode("utf-8"))
274
+ return str(info.get("webSocketDebuggerUrl") or "")
275
+
276
+ def hold(port, page, request):
277
+ """Emulate the launch page and then every page target Chrome opens later, for as long as this
278
+ process lives (#221, #623). One browser-level socket with flattened sessions: the launch page is
279
+ attached by id and reloaded so scripts that read the UA at load see it; a target that appears
280
+ later is attached the moment it exists, sent the same overrides, and reloaded once after its
281
+ first real navigation commits (touch emulation reaches a document only when it loads under it).
282
+ Two things this loop must never do (measured on a real desktop, 2026-09-04): pause new targets
283
+ (waitForDebuggerOnStart) and wait for their replies. A popup a participant taps open shares its
284
+ opener's renderer, answers Emulation commands only once it runs, and a loop blocked on that
285
+ reply never resumed the tab (it loaded forever) and never reached the next one. Overrides are
286
+ sent without waiting; replies, errors included, are logged as they arrive.
287
+ Prints one JSON line per attached target; the first line is the announce the lane reads."""
288
+ launch_id = str(page.get("id") or "")
289
+ try:
290
+ ws = Ws(browser_ws_url(port), timeout=5)
291
+ except Exception as error:
292
+ print(json.dumps({"unavailable": "the browser socket could not be opened (%s)" % type(error).__name__}), flush=True)
293
+ return
294
+ attach = ws.call("Target.attachToTarget", {"targetId": launch_id, "flatten": True})
295
+ session_id = ((attach or {}).get("result") or {}).get("sessionId") if isinstance(attach, dict) else None
296
+ if not session_id:
297
+ print(json.dumps({"unavailable": "Target.attachToTarget failed for the launch page"}), flush=True)
298
+ return
299
+ covered = {launch_id}
300
+ applied, failure = apply_over(ws, request, session_id, reload=True)
301
+ print(json.dumps({"applied": applied, "held": failure is None, "targetId": launch_id, **({"unavailable": failure} if failure else {})}), flush=True)
302
+ if failure:
303
+ return
304
+ auto = ws.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
305
+ if not isinstance(auto, dict) or "error" in auto:
306
+ print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; later tabs are not emulated"}), flush=True)
307
+ ws.sock.settimeout(None)
308
+ pending = {}
309
+ # Sessions of later targets that still owe one reload: touch emulation reaches a document only
310
+ # when it loads under the override (measured: a popup's first document reported
311
+ # maxTouchPoints 0 with the viewport already 414 px), so each later tab is reloaded once after
312
+ # its first real navigation commits, or at once when it had already committed at attach time.
313
+ reload_owed = {}
314
+ while True:
315
+ try:
316
+ message = ws.events.pop(0) if ws.events else ws.recv()
317
+ except Exception as error:
318
+ print(json.dumps({"holderExit": "%s: %s" % (type(error).__name__, error)}), flush=True)
319
+ return
320
+ if message is None:
321
+ print(json.dumps({"holderExit": "browser socket closed"}), flush=True)
322
+ return
323
+ reply_id = message.get("id")
324
+ session = message.get("sessionId")
325
+ if reply_id in pending:
326
+ method = pending.pop(reply_id)
327
+ if "error" in message:
328
+ print(json.dumps({"replyError": method, "message": str((message.get("error") or {}).get("message"))}), flush=True)
329
+ elif method == "href" and session in reload_owed:
330
+ # The navigation may have committed before Page.enable could report it; the page's
331
+ # own location says so, and the reload it is owed goes out now (once: pop guards it).
332
+ href = str((((message.get("result") or {}).get("result") or {}).get("value")) or "")
333
+ if href and not href.startswith("about:"):
334
+ target_id = reload_owed.pop(session)
335
+ pending[ws.send("Page.reload", {}, session)] = "Page.reload"
336
+ print(json.dumps({"reloaded": target_id, "url": href, "by": "href"}), flush=True)
337
+ continue
338
+ method = message.get("method")
339
+ params = message.get("params") or {}
340
+ if method == "Page.frameNavigated" and session in reload_owed:
341
+ frame = params.get("frame") or {}
342
+ url = str(frame.get("url") or "")
343
+ if not frame.get("parentId") and url and not url.startswith("about:"):
344
+ target_id = reload_owed.pop(session)
345
+ pending[ws.send("Page.reload", {}, session)] = "Page.reload"
346
+ print(json.dumps({"reloaded": target_id, "url": url}), flush=True)
347
+ continue
348
+ if method != "Target.attachedToTarget":
349
+ continue
350
+ info = params.get("targetInfo") or {}
351
+ new_session = params.get("sessionId")
352
+ target_id = str(info.get("targetId") or "")
353
+ if info.get("type") == "page" and target_id and target_id not in covered:
354
+ covered.add(target_id)
355
+ messages = emulation_messages(request, reload=False)
356
+ for m in messages:
357
+ pending[ws.send(m["method"], m.get("params"), new_session)] = m["method"]
358
+ pending[ws.send("Page.enable", {}, new_session)] = "Page.enable"
359
+ url = str(info.get("url") or "")
360
+ if url and not url.startswith("about:"):
361
+ pending[ws.send("Page.reload", {}, new_session)] = "Page.reload"
362
+ print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages] + ["Page.reload"], "url": url}), flush=True)
363
+ else:
364
+ reload_owed[new_session] = target_id
365
+ # Two ways to learn the first real navigation committed, whichever answers first:
366
+ # Page.frameNavigated (if Page.enable landed before the commit) or the page's own
367
+ # location.href (if it did not).
368
+ pending[ws.send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True}, new_session)] = "href"
369
+ print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages], "reloadAfterNavigation": True}), flush=True)
370
+ if params.get("waitingForDebugger"):
371
+ ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
182
372
 
183
373
  def main():
184
374
  args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
@@ -196,7 +386,30 @@ def main():
196
386
  if page is None:
197
387
  print(json.dumps({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)}))
198
388
  return
199
- ws_url = page.get("webSocketDebuggerUrl")
389
+ # Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
390
+ # page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
391
+ ws_url = page.get("webSocketDebuggerUrl") or (
392
+ "ws://127.0.0.1:%d/devtools/page/%s" % (port, page.get("id")) if page.get("id") else None
393
+ )
394
+ if mode == "emulate":
395
+ applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
396
+ if failure is not None:
397
+ print(json.dumps({"unavailable": failure, "applied": applied or []}))
398
+ return
399
+ print(json.dumps({"applied": applied, "targetId": str(page.get("id") or "")}))
400
+ return
401
+ if mode == "hold":
402
+ # Prints its announce line, then stays attached (and attaches to every later page target)
403
+ # until killed.
404
+ hold(port, page, args.get("emulation") or {})
405
+ return
406
+ if mode == "fidelity":
407
+ result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
408
+ if not isinstance(result, dict):
409
+ print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
410
+ return
411
+ print(json.dumps({"fidelity": result, "targetId": str(page.get("id") or "")}))
412
+ return
200
413
  if mode == "geometry":
201
414
  result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
202
415
  if not isinstance(result, dict):
@@ -215,7 +428,7 @@ def main():
215
428
  title = result["title"] if isinstance(result.get("title"), str) else title
216
429
  text = result["text"] if isinstance(result.get("text"), str) else ""
217
430
  scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
218
- print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y}))
431
+ print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")}))
219
432
 
220
433
  main()
221
434
  `;
@@ -233,6 +446,8 @@ export function chromeCdpProbeCommand(args) {
233
446
  payload.targetId = args.targetId;
234
447
  if (args.prefer !== undefined)
235
448
  payload.prefer = args.prefer;
449
+ if (args.emulation !== undefined)
450
+ payload.emulation = args.emulation;
236
451
  return `python3 -c ${shellSingleQuote(CHROME_CDP_PROBE_PY)} ${shellSingleQuote(JSON.stringify(payload))}`;
237
452
  }
238
453
  /**
@@ -254,8 +469,12 @@ export function parseChromeCdpProbeOutput(stdout) {
254
469
  if (!parsed || typeof parsed !== "object")
255
470
  return { unavailable: "probe output was not an object" };
256
471
  const record = parsed;
257
- if (typeof record.unavailable === "string")
258
- return { unavailable: record.unavailable };
472
+ const applied = Array.isArray(record.applied)
473
+ ? record.applied.filter((item) => typeof item === "string")
474
+ : undefined;
475
+ if (typeof record.unavailable === "string") {
476
+ return { unavailable: record.unavailable, ...(applied === undefined ? {} : { applied }) };
477
+ }
259
478
  const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
260
479
  const box = (value, keys) => {
261
480
  if (!value || typeof value !== "object")
@@ -274,7 +493,23 @@ export function parseChromeCdpProbeOutput(stdout) {
274
493
  const viewport = box(record.viewport, ["width", "height", "deviceScaleFactor"]);
275
494
  const cdpPort = numberOr(record.cdpPort);
276
495
  const scrollY = numberOr(record.scrollY);
496
+ const fidelity = (() => {
497
+ const raw = record.fidelity;
498
+ if (!raw || typeof raw !== "object")
499
+ return undefined;
500
+ const source = raw;
501
+ const dpr = numberOr(source.devicePixelRatio);
502
+ const innerWidth = numberOr(source.innerWidth);
503
+ const innerHeight = numberOr(source.innerHeight);
504
+ const maxTouchPoints = numberOr(source.maxTouchPoints);
505
+ if (typeof source.userAgent !== "string" || dpr === undefined || innerWidth === undefined || innerHeight === undefined || maxTouchPoints === undefined) {
506
+ return undefined;
507
+ }
508
+ return { userAgent: source.userAgent, devicePixelRatio: dpr, innerWidth, innerHeight, maxTouchPoints, coarsePointer: source.coarsePointer === true };
509
+ })();
277
510
  return {
511
+ ...(applied === undefined ? {} : { applied }),
512
+ ...(fidelity === undefined ? {} : { fidelity }),
278
513
  ...(cdpPort === undefined ? {} : { cdpPort }),
279
514
  ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
280
515
  ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
@@ -1 +1 @@
1
- {"version":3,"file":"chrome-cdp-probe.js","sourceRoot":"","sources":["../src/chrome-cdp-probe.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"chrome-cdp-probe.js","sourceRoot":"","sources":["../src/chrome-cdp-probe.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,EAAE;AACF,iGAAiG;AACjG,8FAA8F;AAC9F,8FAA8F;AAC9F,kGAAkG;AAClG,0FAA0F;AAC1F,kGAAkG;AAClG,gGAAgG;AAChG,mGAAmG;AACnG,oDAAoD;AACpD,EAAE;AACF,kGAAkG;AAClG,4FAA4F;AAC5F,EAAE;AACF,8EAA8E;AAC9E,kGAAkG;AAClG,gGAAgG;AAChG,iEAAiE;AA0EjE;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoZ5C,CAAC;AAEF,SAAS,gBAAgB,CAAC,KAAa;IACrC,OAAO,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC;AAC7C,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,qBAAqB,CAAC,IAAwB;IAC5D,MAAM,OAAO,GAA4B,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;IACxF,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;IAC/D,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;QAAE,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;IACxE,IAAI,IAAI,CAAC,QAAQ,KAAK,SAAS;QAAE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;IAClE,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;QAAE,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC5D,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS;QAAE,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;IACrE,OAAO,cAAc,gBAAgB,CAAC,mBAAmB,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;AAC5G,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,MAA0B;IAClE,MAAM,OAAO,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACtC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC;IAC1E,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,WAAW,EAAE,2BAA2B,EAAE,CAAC;IACtD,CAAC;IACD,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,EAAE,WAAW,EAAE,gCAAgC,EAAE,CAAC;IACpG,MAAM,MAAM,GAAG,MAAiC,CAAC;IACjD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC;QAC3C,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC;QAC3E,CAAC,CAAC,SAAS,CAAC;IACd,IAAI,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,EAAE,CAAC;QAC3C,OAAO,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IAC5F,CAAC;IACD,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAsB,EAAE,CACtD,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1E,MAAM,GAAG,GAAG,CAAC,KAAc,EAAE,IAAc,EAAsC,EAAE;QACjF,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAC1D,MAAM,MAAM,GAAG,KAAgC,CAAC;QAChD,MAAM,GAAG,GAA2B,EAAE,CAAC;QACvC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YAChC,IAAI,CAAC,KAAK,SAAS;gBAAE,OAAO,SAAS,CAAC;YACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC,CAAC;IACF,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,CAA0C,CAAC;IACxH,MAAM,QAAQ,GAAG,GAAG,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,mBAAmB,CAAC,CAAqC,CAAC;IACpH,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,QAAQ,GAAG,CAAC,GAAmC,EAAE;QACrD,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC;QAC5B,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QACtD,MAAM,MAAM,GAAG,GAA8B,CAAC;QAC9C,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QAC9C,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC/C,MAAM,WAAW,GAAG,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QACjD,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;QACvD,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,IAAI,GAAG,KAAK,SAAS,IAAI,UAAU,KAAK,SAAS,IAAI,WAAW,KAAK,SAAS,IAAI,cAAc,KAAK,SAAS,EAAE,CAAC;YACvJ,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;IACvJ,CAAC,CAAC,EAAE,CAAC;IACL,OAAO;QACL,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC;QAC7C,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC;QAC/C,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC;QAC7C,GAAG,CAAC,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvF,GAAG,CAAC,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3F,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC;QAC7C,GAAG,CAAC,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3G,GAAG,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC;QACzD,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC;KAChD,CAAC;AACJ,CAAC"}
@@ -10,6 +10,7 @@ import { type E2BDesktopModule, type E2BDesktopSandbox } from "./e2b-desktop-lau
10
10
  import { type DetachedTimers } from "./e2b-detached.js";
11
11
  import { type DevicePreset } from "./device-presets.js";
12
12
  import { type LabActorLane, type LabCommsEmail, type LabCommsRecipient, type LabConfig, type LabDesktopBrowser, type LabSubjectServe, type LabSubjectState } from "./lab-config.js";
13
+ import { type ChromeMobileEmulationRequest } from "./chrome-cdp-probe.js";
13
14
  import { type ResolvedPersona } from "./persona.js";
14
15
  import { type LabTask } from "./tasks.js";
15
16
  import { renderObserver, type ObserverResult } from "./observer.js";
@@ -684,7 +685,26 @@ export interface ChromeCdpEndpoint {
684
685
  * The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
685
686
  * url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
686
687
  */
687
- export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<{
688
+ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void,
689
+ /**
690
+ * Mobile emulation on later tabs (#623): the holder attaches to every page target Chrome opens
691
+ * after the launch page, so a tab the participant opens later should lay out at the phone width
692
+ * too. The first observation on each new target reads that page's OWN report; a target that
693
+ * reports the requested width is recorded through `onCovered`, and one that does not (or cannot
694
+ * be read) fires `onDrift` once, so a phone-labelled lane that spent part of its session at
695
+ * desktop layout says so with the number the page gave.
696
+ */
697
+ drift?: {
698
+ emulatedTargetId: string;
699
+ expectedWidth: number;
700
+ expectTouch?: boolean;
701
+ onDrift: (reason: string) => void;
702
+ onCovered?: (targetId: string, read: {
703
+ innerWidth: number;
704
+ devicePixelRatio: number;
705
+ maxTouchPoints: number;
706
+ }) => void;
707
+ }): () => Promise<{
688
708
  url?: string;
689
709
  title?: string;
690
710
  text?: string;
@@ -700,6 +720,20 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
700
720
  export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
701
721
  targetId?: string;
702
722
  }) | undefined>;
723
+ /** The user agent a mobile-emulated lane presents unless the lab sets its own. */
724
+ export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
725
+ /**
726
+ * Apply mobile emulation (#221) to the lane's launch page and read back what the page reports.
727
+ * Fails CLOSED: a request that cannot be applied throws, because a desktop run labelled mobile is
728
+ * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
729
+ * (the emulation was applied; only the proof is missing).
730
+ */
731
+ export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId: string | undefined, request: ChromeMobileEmulationRequest): Promise<{
732
+ fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
733
+ warnings: string[];
734
+ targetId?: string;
735
+ holderName: string;
736
+ }>;
703
737
  /** Shared hosted-browser geometry capture used by per-lane and sequential shared-world routes. */
704
738
  export declare function captureDesktopBrowserGeometry(args: {
705
739
  desktop: E2BDesktopSandbox;