dsh-browser-plus 0.5.0 → 0.5.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -233,6 +233,86 @@ function formatSnapshot(snapshot) {
233
233
  : '';
234
234
  return `${header}\n\n${body}${tail}${banner}${user}`;
235
235
  }
236
+ /**
237
+ * The snapshot shape both `browser_open` and `browser_snapshot` return.
238
+ *
239
+ * Shared on purpose: a tool output schema rejects unknown properties, so a
240
+ * field declared on only one of the two schemas is silently dropped before the
241
+ * model ever sees it — which is exactly what happened to `frames` and the
242
+ * element `frame` index when iframe support landed (declared on browser_open,
243
+ * missing from browser_snapshot, and dropped from browser_open's own mapper).
244
+ */
245
+ const SNAPSHOT_OUTPUT_SCHEMA = {
246
+ type: 'object',
247
+ additionalProperties: false,
248
+ properties: {
249
+ snapshotId: { type: 'string', required: true },
250
+ url: { type: 'string', required: true },
251
+ title: { type: 'string' },
252
+ truncated: { type: 'boolean' },
253
+ frames: {
254
+ type: 'array',
255
+ description: 'Every iframe on the page; readable:false marks the cross-origin ones whose contents cannot be read.',
256
+ items: {
257
+ type: 'object',
258
+ additionalProperties: false,
259
+ properties: {
260
+ index: { type: 'number', required: true },
261
+ url: { type: 'string', required: true },
262
+ readable: { type: 'boolean', required: true },
263
+ },
264
+ },
265
+ },
266
+ elements: {
267
+ type: 'array',
268
+ required: true,
269
+ items: {
270
+ type: 'object',
271
+ additionalProperties: false,
272
+ properties: {
273
+ ref: { type: 'number', required: true },
274
+ kind: { type: 'string', required: true },
275
+ label: { type: 'string', required: true },
276
+ x: { type: 'number', required: true },
277
+ y: { type: 'number', required: true },
278
+ loc: { type: 'string', required: true },
279
+ frame: { type: 'number', description: 'Index into frames when this element lives inside an iframe.' },
280
+ },
281
+ },
282
+ },
283
+ challenge: {
284
+ type: 'object',
285
+ additionalProperties: false,
286
+ properties: {
287
+ blocked: { type: 'boolean', required: true },
288
+ kind: { type: 'string' },
289
+ reason: { type: 'string' },
290
+ },
291
+ },
292
+ userControlling: { type: 'boolean' },
293
+ },
294
+ };
295
+ /** Project a provider snapshot onto that shared model-facing shape. */
296
+ function snapshotOutput(snapshot) {
297
+ return {
298
+ snapshotId: snapshot.snapshotId,
299
+ url: snapshot.url,
300
+ ...snapshot.title !== undefined ? { title: snapshot.title } : {},
301
+ ...snapshot.frames !== undefined ? { frames: snapshot.frames.map(frame => ({ index: frame.index, url: frame.url, readable: frame.readable })) } : {},
302
+ elements: snapshot.elements.map(el => ({
303
+ ref: el.ref,
304
+ kind: el.kind,
305
+ label: el.label,
306
+ x: el.x,
307
+ y: el.y,
308
+ loc: el.loc,
309
+ ...el.frame !== undefined ? { frame: el.frame } : {},
310
+ })),
311
+ truncated: snapshot.truncated,
312
+ ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
313
+ ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
314
+ };
315
+ }
236
316
  /** Register all browser tools with `ctx.tools`. */
237
317
  export function apply(ctx, config = {}) {
238
318
  const timeoutMs = config.timeoutMs ?? 60_000;
@@ -261,42 +341,7 @@ export function apply(ctx, config = {}) {
261
341
  space: { type: 'string', description: 'Optional browser-task label shown in the task manager and active window title.' },
262
342
  },
263
343
  output: {
264
- schema: {
265
- type: 'object',
266
- additionalProperties: false,
267
- properties: {
268
- snapshotId: { type: 'string', required: true },
269
- url: { type: 'string', required: true },
270
- title: { type: 'string' },
271
- truncated: { type: 'boolean' },
272
- elements: {
273
- type: 'array',
274
- required: true,
275
- items: {
276
- type: 'object',
277
- additionalProperties: false,
278
- properties: {
279
- ref: { type: 'number', required: true },
280
- kind: { type: 'string', required: true },
281
- label: { type: 'string', required: true },
282
- x: { type: 'number', required: true },
283
- y: { type: 'number', required: true },
284
- loc: { type: 'string', required: true },
285
- },
286
- },
287
- },
288
- challenge: {
289
- type: 'object',
290
- additionalProperties: false,
291
- properties: {
292
- blocked: { type: 'boolean', required: true },
293
- kind: { type: 'string' },
294
- reason: { type: 'string' },
295
- },
296
- },
297
- userControlling: { type: 'boolean' },
298
- },
299
- },
344
+ schema: SNAPSHOT_OUTPUT_SCHEMA,
300
345
  render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
301
346
  },
302
347
  timeoutMs,
@@ -313,15 +358,7 @@ export function apply(ctx, config = {}) {
313
358
  ...args.newTab === true ? { newTab: true } : {},
314
359
  }, exec.signal);
315
360
  const snapshot = await browser.snapshot(session, {}, exec.signal);
316
- return {
317
- snapshotId: snapshot.snapshotId,
318
- url: snapshot.url,
319
- ...snapshot.title !== undefined ? { title: snapshot.title } : {},
320
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, loc: el.loc })),
321
- truncated: snapshot.truncated,
322
- ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
323
- ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
324
- };
361
+ return snapshotOutput(snapshot);
325
362
  }, args.space);
326
363
  },
327
364
  }));
@@ -546,42 +583,7 @@ export function apply(ctx, config = {}) {
546
583
  limit: { type: 'number', description: 'Maximum number of elements to return (1-1000; default 60).' },
547
584
  },
548
585
  output: {
549
- schema: {
550
- type: 'object',
551
- additionalProperties: false,
552
- properties: {
553
- snapshotId: { type: 'string', required: true },
554
- url: { type: 'string', required: true },
555
- title: { type: 'string' },
556
- truncated: { type: 'boolean' },
557
- elements: {
558
- type: 'array',
559
- required: true,
560
- items: {
561
- type: 'object',
562
- additionalProperties: false,
563
- properties: {
564
- ref: { type: 'number', required: true },
565
- kind: { type: 'string', required: true },
566
- label: { type: 'string', required: true },
567
- x: { type: 'number', required: true },
568
- y: { type: 'number', required: true },
569
- loc: { type: 'string', required: true },
570
- },
571
- },
572
- },
573
- challenge: {
574
- type: 'object',
575
- additionalProperties: false,
576
- properties: {
577
- blocked: { type: 'boolean', required: true },
578
- kind: { type: 'string' },
579
- reason: { type: 'string' },
580
- },
581
- },
582
- userControlling: { type: 'boolean' },
583
- },
584
- },
586
+ schema: SNAPSHOT_OUTPUT_SCHEMA,
585
587
  render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
586
588
  },
587
589
  timeoutMs,
@@ -595,15 +597,7 @@ export function apply(ctx, config = {}) {
595
597
  ...args.query !== undefined ? { query: args.query } : {},
596
598
  ...args.limit !== undefined ? { limit: args.limit } : {},
597
599
  }, exec.signal));
598
- return {
599
- snapshotId: snapshot.snapshotId,
600
- url: snapshot.url,
601
- ...snapshot.title !== undefined ? { title: snapshot.title } : {},
602
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, loc: el.loc })),
603
- truncated: snapshot.truncated,
604
- ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
605
- ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
606
- };
600
+ return snapshotOutput(snapshot);
607
601
  },
608
602
  }));
609
603
  ctx.tools.register(defineTool({
@@ -1171,9 +1165,11 @@ export function apply(ctx, config = {}) {
1171
1165
  }));
1172
1166
  ctx.tools.register(defineTool({
1173
1167
  name: 'browser_wait_for',
1174
- description: 'Wait until an element matching a CSS selector appears (and is visible), polling every 250ms. Use before interacting with dynamically-loaded content (SPA views, toasts, menus).',
1168
+ description: 'Wait until a selector, some text, or both reaches a state, polling every 250ms. `state` is visible (default), attached, hidden or detached — hidden/detached are how you wait for something to go away. Use before clicking or reading, instead of sleeping.',
1175
1169
  parameters: {
1176
- selector: { type: 'string', required: true, description: 'CSS selector to wait for.' },
1170
+ selector: { type: 'string', description: 'CSS selector to wait for. Omit to watch the document text (give `text`).' },
1171
+ text: { type: 'string', description: 'Text that must be present: inside the matched element when a selector is given, otherwise anywhere in the document.' },
1172
+ state: { type: 'string', enum: ['visible', 'attached', 'hidden', 'detached'], description: 'Which state to wait for. visible (default) needs a 4x4 px element that is not display:none / visibility:hidden; attached only needs it to exist; hidden and detached wait for it to go away.' },
1177
1173
  timeoutMs: { type: 'number', description: `Total budget in ms (default 15000), capped below this tool's ${String(timeoutMs)}ms budget.` },
1178
1174
  visible: { type: 'boolean', description: 'Require visibility (>4x4 px, not display:none). Default true.' },
1179
1175
  },
@@ -1183,12 +1179,18 @@ export function apply(ctx, config = {}) {
1183
1179
  additionalProperties: false,
1184
1180
  properties: {
1185
1181
  found: { type: 'boolean', required: true },
1182
+ state: { type: 'string', required: true },
1186
1183
  selector: { type: 'string', required: true },
1187
1184
  tag: { type: 'string', required: true },
1188
1185
  text: { type: 'string' },
1189
1186
  },
1190
1187
  },
1191
- render: (_args, value) => [{ type: 'text', text: `Found <${value.tag}> ${value.selector}${value.text !== undefined ? ` — "${value.text.slice(0, 80)}"` : ''}.` }],
1188
+ render: (_args, value) => [{
1189
+ type: 'text',
1190
+ text: value.selector === ''
1191
+ ? `Found text "${(value.text ?? '').slice(0, 80)}" (state "${value.state}").`
1192
+ : `Found <${value.tag}> ${value.selector}${value.text !== undefined ? ` — "${value.text.slice(0, 80)}"` : ''}.`,
1193
+ }],
1192
1194
  },
1193
1195
  timeoutMs,
1194
1196
  isConcurrencySafe: () => true,
@@ -1198,11 +1200,13 @@ export function apply(ctx, config = {}) {
1198
1200
  if (browser === undefined)
1199
1201
  throw new Error('tool-browser: browser service unavailable');
1200
1202
  const result = await withTaskAction(browser, taskKey(exec), 'wait for element', exec, session => browser.waitForElement(session, {
1201
- selector: args.selector,
1203
+ ...args.selector !== undefined ? { selector: args.selector } : {},
1204
+ ...args.text !== undefined ? { text: args.text } : {},
1205
+ ...args.state !== undefined ? { state: args.state } : {},
1202
1206
  timeoutMs: withinToolBudget(args.timeoutMs, 15_000),
1203
1207
  ...args.visible !== undefined ? { visible: args.visible } : {},
1204
1208
  }, exec.signal));
1205
- return { found: true, selector: result.selector, tag: result.tag, text: result.text };
1209
+ return { found: true, state: result.state, selector: result.selector, tag: result.tag, text: result.text };
1206
1210
  },
1207
1211
  }));
1208
1212
  ctx.tools.register(defineTool({
@@ -1441,6 +1445,97 @@ export function apply(ctx, config = {}) {
1441
1445
  };
1442
1446
  },
1443
1447
  }));
1448
+ ctx.tools.register(defineTool({
1449
+ name: 'browser_pdf',
1450
+ description: 'Print the active tab to a PDF file — Chrome\'s "Save as PDF". The path must be inside the browser write roots. Returns the path and byte size.',
1451
+ parameters: {
1452
+ savePath: { type: 'string', required: true, description: 'Absolute path of the .pdf to write. Must be inside the browser write roots.' },
1453
+ landscape: { type: 'boolean', description: 'Landscape orientation. Default portrait.' },
1454
+ printBackground: { type: 'boolean', description: 'Include background colours and images. Default true — dark pages would otherwise print as white paper.' },
1455
+ paperWidth: { type: 'number', description: 'Paper width in inches. Default 8.5.' },
1456
+ paperHeight: { type: 'number', description: 'Paper height in inches. Default 11.' },
1457
+ },
1458
+ output: {
1459
+ schema: {
1460
+ type: 'object',
1461
+ additionalProperties: false,
1462
+ properties: {
1463
+ path: { type: 'string', required: true },
1464
+ bytes: { type: 'number', required: true },
1465
+ },
1466
+ },
1467
+ render: (_args, value) => [{ type: 'text', text: `Wrote ${value.bytes} bytes to ${value.path}` }],
1468
+ },
1469
+ timeoutMs,
1470
+ isConcurrencySafe: () => true,
1471
+ async execute(args, exec) {
1472
+ assertAllowed('browser_pdf', exec);
1473
+ const browser = ctx.get('browser');
1474
+ if (browser === undefined)
1475
+ throw new Error('tool-browser: browser service unavailable');
1476
+ const result = await withTaskAction(browser, taskKey(exec), 'print to pdf', exec, session => browser.pdf(session, {
1477
+ savePath: args.savePath,
1478
+ ...args.landscape !== undefined ? { landscape: args.landscape } : {},
1479
+ ...args.printBackground !== undefined ? { printBackground: args.printBackground } : {},
1480
+ ...args.paperWidth !== undefined ? { paperWidth: args.paperWidth } : {},
1481
+ ...args.paperHeight !== undefined ? { paperHeight: args.paperHeight } : {},
1482
+ }, exec.signal));
1483
+ return { path: result.path, bytes: result.bytes };
1484
+ },
1485
+ }));
1486
+ ctx.tools.register(defineTool({
1487
+ name: 'browser_highlight',
1488
+ description: 'Draw the DevTools-style highlight box over the first element matching a CSS selector, so a human watching the window can see what the Agent is about to touch. It goes through the CDP overlay, so the page DOM is never modified. Pass clear: true to remove it.',
1489
+ parameters: {
1490
+ selector: { type: 'string', description: 'CSS selector whose first match to highlight. Required unless clear is true.' },
1491
+ clear: { type: 'boolean', description: 'Remove the highlight instead of drawing one.' },
1492
+ },
1493
+ output: {
1494
+ schema: {
1495
+ type: 'object',
1496
+ additionalProperties: false,
1497
+ properties: {
1498
+ matched: { type: 'boolean', required: true },
1499
+ cleared: { type: 'boolean', required: true },
1500
+ nodeId: { type: 'number' },
1501
+ box: {
1502
+ type: 'object',
1503
+ additionalProperties: false,
1504
+ properties: {
1505
+ x: { type: 'number', required: true },
1506
+ y: { type: 'number', required: true },
1507
+ width: { type: 'number', required: true },
1508
+ height: { type: 'number', required: true },
1509
+ },
1510
+ },
1511
+ },
1512
+ },
1513
+ render: (_args, value) => [{
1514
+ type: 'text',
1515
+ text: value.matched
1516
+ ? `Highlighted node ${String(value.nodeId)} at ${Math.round(value.box?.x ?? 0)},${Math.round(value.box?.y ?? 0)} (${Math.round(value.box?.width ?? 0)}x${Math.round(value.box?.height ?? 0)})`
1517
+ : value.cleared ? 'Highlight cleared' : 'No element matched that selector',
1518
+ }],
1519
+ },
1520
+ timeoutMs,
1521
+ isConcurrencySafe: () => true,
1522
+ async execute(args, exec) {
1523
+ assertAllowed('browser_highlight', exec);
1524
+ const browser = ctx.get('browser');
1525
+ if (browser === undefined)
1526
+ throw new Error('tool-browser: browser service unavailable');
1527
+ const result = await withTaskAction(browser, taskKey(exec), 'highlight element', exec, session => browser.highlight(session, {
1528
+ ...args.selector !== undefined ? { selector: args.selector } : {},
1529
+ ...args.clear !== undefined ? { clear: args.clear } : {},
1530
+ }, exec.signal));
1531
+ return {
1532
+ matched: result.matched,
1533
+ cleared: result.cleared,
1534
+ ...result.nodeId !== undefined ? { nodeId: result.nodeId } : {},
1535
+ ...result.box !== undefined ? { box: result.box } : {},
1536
+ };
1537
+ },
1538
+ }));
1444
1539
  if (config.tabTools !== false) {
1445
1540
  ctx.tools.register(defineTool({
1446
1541
  name: 'browser_list_tabs',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-plus",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Enhanced shared browser for DeepSeek Harness: one visible window with isolated task views, a page task manager, non-stealing background tasks, built-in toolbar and operation trail, and pinned Electron 42.9.3 compositing.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,6 +22,21 @@
22
22
  "types": "./lib/tool-browser/index.d.ts",
23
23
  "default": "./lib/tool-browser/index.js"
24
24
  },
25
+ "./command-browser": {
26
+ "types": "./lib/command-browser/index.d.ts",
27
+ "default": "./lib/command-browser/index.js"
28
+ },
29
+ "./http-browser": {
30
+ "types": "./lib/http-browser/index.d.ts",
31
+ "default": "./lib/http-browser/index.js"
32
+ },
33
+ "./task-todos": {
34
+ "types": "./lib/task-todos/index.d.ts",
35
+ "default": "./lib/task-todos/index.js"
36
+ },
37
+ "./client": {
38
+ "default": "./lib/client.js"
39
+ },
25
40
  "./host-main": {
26
41
  "default": "./lib/browser-electron/host-main.js"
27
42
  },
@@ -31,6 +46,7 @@
31
46
  "files": [
32
47
  "lib",
33
48
  "src",
49
+ "client",
34
50
  "docs",
35
51
  "assets",
36
52
  "scripts",
@@ -43,8 +59,9 @@
43
59
  "NOTICE.md"
44
60
  ],
45
61
  "scripts": {
46
- "build": "npm run build:icons && tsc -p tsconfig.json",
62
+ "build": "npm run build:icons && tsc -p tsconfig.json && npm run build:client",
47
63
  "build:icons": "node scripts/build-icons.mjs",
64
+ "build:client": "node scripts/build-client.mjs",
48
65
  "pretest": "tsc -p tsconfig.json",
49
66
  "test": "node --test test/*.test.mjs",
50
67
  "test:only": "node --test test/*.test.mjs",
@@ -52,6 +69,7 @@
52
69
  "smoke:electron-host": "node scripts/smoke-electron-host.mjs",
53
70
  "smoke:browser-tools": "node scripts/smoke-browser-tools.mjs",
54
71
  "smoke:chrome-world": "node scripts/smoke-chrome-world.mjs",
72
+ "test:orb-drag": "node scripts/test-orb-drag.mjs",
55
73
  "prepack": "npm run build"
56
74
  },
57
75
  "keywords": [
@@ -89,6 +107,13 @@
89
107
  "dsh": {
90
108
  "bundle": {
91
109
  "patch": "./cordis.patch.yml"
110
+ },
111
+ "client": {
112
+ "inject": [
113
+ "@deepseek-ai/dsh-client-ui-slots",
114
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
115
+ ],
116
+ "platform": "web"
92
117
  }
93
118
  },
94
119
  "optionalDependencies": {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Build the browser bundle: copy the single-file client source to lib/client.js.
3
+ *
4
+ * The client-modules loader serves the package's \`./client\` export, and the
5
+ * bundle is one file that only requires platform modules (React), so there is
6
+ * nothing to bundle or minify — the copy keeps the shipped artifact identical to
7
+ * the reviewed source. Run by \`npm run build\` and by \`prepack\`.
8
+ */
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
10
+
11
+ const source = new URL('../client/index.js', import.meta.url)
12
+ const output = new URL('../lib/client.js', import.meta.url)
13
+
14
+ const body = await readFile(source, 'utf8')
15
+ if (!body.includes('__ModuleLoader__.load')) {
16
+ throw new Error('client/index.js must register through window.__ModuleLoader__.load')
17
+ }
18
+ await mkdir(new URL('../lib/', import.meta.url), { recursive: true })
19
+ await writeFile(output, body)
20
+ process.stdout.write('built lib/client.js (' + String(body.length) + ' bytes)\n')
@@ -35,9 +35,14 @@ const check = async (name, fn) => {
35
35
  }
36
36
  }
37
37
 
38
- const host = new RemoteElectronViewHost(defaultHostMainPath())
38
+ // 这套检查**探 chrome 的内部状态**(window.__dshTabs / __dshChromeMotion / __dshBookmarks…),
39
+ // 而默认的 isolated 世界把这些放在 chrome 自己的世界里 —— 从页面世界读到的全是 undefined。
40
+ // 所以行为套件显式跑 main;「隔离是否真的隔离、chrome 在隔离世界里能不能挂起来」由
41
+ // `npm run smoke:chrome-world` 负责(它同时跑两种模式做 A/B)。
42
+ const host = new RemoteElectronViewHost(defaultHostMainPath(), { chromeWorld: 'main' })
39
43
  const provider = new ElectronBrowserProvider(host, { writeRoots: [outDir], readRoots: [outDir] })
40
44
 
45
+
41
46
  try {
42
47
  const session = await provider.open()
43
48
  console.log('session', session)
@@ -63,6 +68,21 @@ try {
63
68
  return 'no error'
64
69
  })
65
70
  await check('waitForElement', async () => (await provider.waitForElement(session, { selector: 'body' })).tag)
71
+ // 纯文字等待:没有选择器可量,状态必须由「文字在不在」决定。
72
+ // 这一条曾经永远超时(脚本仍按 visibleNow(null) === false 判定)。
73
+ await check('waitForElement by text', async () => (await provider.waitForElement(session, { text: 'This domain is for use' })).state)
74
+ // 真机打印:Electron 的 debugger 没有 CDP Page.printToPDF,
75
+ // 所以这条只有走宿主的 webContents.printToPDF 才可能过。
76
+ await check('pdf', async () => {
77
+ const out = join(outDir, 'page.pdf')
78
+ const result = await provider.pdf(session, { savePath: out })
79
+ const bytes = readFileSync(out)
80
+ if (bytes.subarray(0, 5).toString() !== '%PDF-') throw new Error('not a PDF: ' + bytes.subarray(0, 8).toString())
81
+ if (bytes.length !== result.bytes) throw new Error('byte count mismatch: ' + String(bytes.length) + ' vs ' + String(result.bytes))
82
+ const pages = (bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length
83
+ if (pages < 1) throw new Error('the printed document has no page objects')
84
+ return { bytes: bytes.length, pages }
85
+ })
66
86
  await check('scrape concurrent', async () => {
67
87
  const out = join(outDir, 'rows.jsonl')
68
88
  const started = await provider.startScrape(session, {
@@ -1801,6 +1821,69 @@ await check('a confirm dialog can be dismissed, not only accepted', async () =>
1801
1821
  await new Promise(resolve => setTimeout(resolve, 800))
1802
1822
  }
1803
1823
  })
1824
+ // 真机:同源子框架里的控件要能看到(元素带 frame 序号、坐标加上框架的矩形);
1825
+ // 跨源框架的 contentDocument 读不到,必须明确记成 readable:false —— 不假装能读。
1826
+ await check('the snapshot sees into same-origin frames and names the cross-origin ones', async () => {
1827
+ const size = await laidOut()
1828
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1829
+ const { createServer } = await import('node:http')
1830
+ const page = (port) => '<!doctype html><title>frames</title>'
1831
+ + '<button id="outer">Outer Button</button>'
1832
+ + '<iframe id="same" src="/inner" style="width:220px;height:60px"></iframe>'
1833
+ + '<iframe id="cross" src="http://127.0.0.1:' + String(port) + '/inner" style="width:220px;height:60px"></iframe>'
1834
+ const innerPage = '<!doctype html><title>inner</title><button id="inside">Inner Button</button>'
1835
+ const inner = createServer((_request, response) => {
1836
+ response.writeHead(200, { 'content-type': 'text/html' })
1837
+ response.end(innerPage)
1838
+ })
1839
+ await new Promise(resolve => inner.listen(0, '127.0.0.1', resolve))
1840
+ // 路径要路由:主页面里的 src="/inner" 必须拿到内部页面,
1841
+ // 否则框架里装的就是主页面自己(第一版就是这样,量出来才发现)。
1842
+ const outerServer = createServer((request, response) => {
1843
+ response.writeHead(200, { 'content-type': 'text/html' })
1844
+ if (request.url === '/inner') {
1845
+ response.end(innerPage)
1846
+ return
1847
+ }
1848
+ response.end(page(inner.address().port))
1849
+ })
1850
+ await new Promise(resolve => outerServer.listen(0, '127.0.0.1', resolve))
1851
+ try {
1852
+ await provider.navigate(session, { url: 'http://127.0.0.1:' + String(outerServer.address().port) + '/' })
1853
+ let snapshot = null
1854
+ for (let i = 0; i < 25; i += 1) {
1855
+ await new Promise(resolve => setTimeout(resolve, 150))
1856
+ snapshot = await provider.snapshot(session, { limit: 50 })
1857
+ if ((snapshot.frames ?? []).length >= 2) break
1858
+ }
1859
+ const frames = snapshot.frames ?? []
1860
+ if (frames.length < 2) throw new Error('both iframes should be reported: ' + JSON.stringify(frames))
1861
+ const inside = snapshot.elements.find(element => element.label === 'Inner Button')
1862
+ if (inside === undefined) throw new Error('the same-origin frame content was not read: ' + JSON.stringify(snapshot.elements.map(e => e.label)))
1863
+ if (inside.frame !== 0) throw new Error('the frame element did not carry its index: ' + String(inside.frame))
1864
+ const outside = snapshot.elements.find(element => element.label === 'Outer Button')
1865
+ if (outside === undefined || outside.frame !== undefined) throw new Error('a top-level element must not claim a frame')
1866
+ const cross = frames.find(frame => frame.readable === false)
1867
+ if (cross === undefined) throw new Error('the cross-origin frame must be reported unreadable: ' + JSON.stringify(frames))
1868
+ const same = frames.find(frame => frame.readable === true)
1869
+ if (same === undefined) throw new Error('the same-origin frame should be readable: ' + JSON.stringify(frames))
1870
+ // 工具层也必须把这两样透出去。provider 有、工具层丢,才是这个 bug 的
1871
+ // 真正形态:只测 provider 的冒烟一直是绿的。
1872
+ // exec 不带 agent -> taskKey() 是 'default',正好复用上面这个会话。
1873
+ const viaTool = await call('browser_snapshot', { limit: 50 }, {})
1874
+ const toolFrames = viaTool.frames ?? []
1875
+ if (toolFrames.length < 2) throw new Error('the tool layer dropped frames: ' + JSON.stringify(viaTool.frames))
1876
+ const toolInside = (viaTool.elements ?? []).find(element => element.label === 'Inner Button')
1877
+ if (toolInside === undefined || toolInside.frame !== 0) {
1878
+ throw new Error('the tool layer dropped the element frame index: ' + JSON.stringify(toolInside))
1879
+ }
1880
+ return { frames: frames.length, inside: inside.frame, cross: String(cross.url).slice(0, 34) }
1881
+ } finally {
1882
+ outerServer.close()
1883
+ inner.close()
1884
+ }
1885
+ })
1886
+
1804
1887
  // 真机:页面自己的 console 输出和网络请求必须能被读到(这是「为什么没成功」的唯一线索)。
1805
1888
  await check('console and network capture what the page did', async () => {
1806
1889
  const size = await laidOut()
@@ -28,7 +28,7 @@ const probe = async (label, options) => {
28
28
  await view.sendCommand('Page.navigate', { url: 'https://example.com/' })
29
29
  await wait(2500)
30
30
  const reply = await view.sendCommand('Runtime.evaluate', {
31
- expression: 'JSON.stringify({ chromeHost: document.getElementById("__dsh_browser_chrome_host__") !== null, binding: typeof window.__dshBrowserTaskAction, tasks: typeof window.__dshTasks, trail: typeof window.__dshTrail })',
31
+ expression: 'JSON.stringify({ chromeHost: document.getElementById("__dsh_browser_chrome_host__") !== null, painted: (() => { const h = document.getElementById("__dsh_browser_chrome_host__"); if (h === null) return 0; return Math.round(h.getBoundingClientRect().width) })(), binding: typeof window.__dshBrowserTaskAction, tasks: typeof window.__dshTasks, trail: typeof window.__dshTrail })',
32
32
  returnByValue: true,
33
33
  })
34
34
  const value = JSON.parse(reply?.result?.value ?? '{}')
@@ -42,8 +42,10 @@ const probe = async (label, options) => {
42
42
  }
43
43
  }
44
44
 
45
- const main = await probe('main (default)', {})
46
- const isolated = await probe('isolated', { chromeWorld: 'isolated' })
45
+ // 两种模式都**显式**指定:默认值会变(2026-10-04 起默认 isolated),
46
+ // 用 `{}` 表示「main」会让这条检查在默认值一变就静默失效。
47
+ const isolated = await probe('isolated (default)', {})
48
+ const main = await probe('main (opt-in)', { chromeWorld: 'main' })
47
49
 
48
50
  // Assert only what chromeWorld claims. window.__dshBrowserTaskAction is reported
49
51
  // but not asserted: it appears asynchronously after load, so a fixed wait
@@ -60,4 +62,4 @@ if (failures.length > 0) {
60
62
  console.log(JSON.stringify({ ok: false, failures }))
61
63
  process.exit(1)
62
64
  }
63
- console.log(JSON.stringify({ ok: true, note: 'the chrome mounts in both modes; only the default leaks its globals to the page' }))
65
+ console.log(JSON.stringify({ ok: true, note: 'the chrome mounts in both modes; only main (the opt-in bisect path) leaks its globals to the page' }))