@jackwener/opencli 1.7.2 → 1.7.4

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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -18,6 +18,15 @@ export declare abstract class BasePage implements IPage {
18
18
  settleMs?: number;
19
19
  }): Promise<void>;
20
20
  abstract evaluate(js: string): Promise<unknown>;
21
+ /**
22
+ * Safely evaluate JS with pre-serialized arguments.
23
+ * Each key in `args` becomes a `const` declaration with JSON-serialized value,
24
+ * prepended to the JS code. Prevents injection by design.
25
+ *
26
+ * Usage:
27
+ * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
28
+ */
29
+ evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown>;
21
30
  abstract getCookies(opts?: {
22
31
  domain?: string;
23
32
  url?: string;
@@ -8,16 +8,43 @@
8
8
  * Subclasses implement the transport-specific methods: goto, evaluate,
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
- import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
- import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
11
+ import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
+ import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
14
+ import { TargetError } from './target-errors.js';
13
15
  import { formatSnapshot } from '../snapshotFormatter.js';
14
16
  export class BasePage {
15
17
  _lastUrl = null;
16
18
  /** Cached previous snapshot hashes for incremental diff marking */
17
19
  _prevSnapshotHashes = null;
20
+ /**
21
+ * Safely evaluate JS with pre-serialized arguments.
22
+ * Each key in `args` becomes a `const` declaration with JSON-serialized value,
23
+ * prepended to the JS code. Prevents injection by design.
24
+ *
25
+ * Usage:
26
+ * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
27
+ */
28
+ async evaluateWithArgs(js, args) {
29
+ const declarations = Object.entries(args)
30
+ .map(([key, value]) => {
31
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
32
+ throw new Error(`evaluateWithArgs: invalid key "${key}"`);
33
+ }
34
+ return `const ${key} = ${JSON.stringify(value)};`;
35
+ })
36
+ .join('\n');
37
+ return this.evaluate(`${declarations}\n${js}`);
38
+ }
18
39
  // ── Shared DOM helper implementations ──
19
40
  async click(ref) {
20
- const result = await this.evaluate(clickJs(ref));
41
+ // Phase 1: Resolve target with fingerprint verification
42
+ const resolution = await this.evaluate(resolveTargetJs(ref));
43
+ if (!resolution.ok) {
44
+ throw new TargetError(resolution);
45
+ }
46
+ // Phase 2: Execute click on resolved element
47
+ const result = await this.evaluate(clickResolvedJs());
21
48
  // Backwards compat: old format returned 'clicked' string
22
49
  if (typeof result === 'string' || result == null)
23
50
  return;
@@ -37,13 +64,25 @@ export class BasePage {
37
64
  return false;
38
65
  }
39
66
  async typeText(ref, text) {
40
- await this.evaluate(typeTextJs(ref, text));
67
+ // Phase 1: Resolve target with fingerprint verification
68
+ const resolution = await this.evaluate(resolveTargetJs(ref));
69
+ if (!resolution.ok) {
70
+ throw new TargetError(resolution);
71
+ }
72
+ // Phase 2: Execute type on resolved element
73
+ await this.evaluate(typeResolvedJs(text));
41
74
  }
42
75
  async pressKey(key) {
43
76
  await this.evaluate(pressKeyJs(key));
44
77
  }
45
78
  async scrollTo(ref) {
46
- return this.evaluate(scrollToRefJs(ref));
79
+ // Phase 1: Resolve target with fingerprint verification
80
+ const resolution = await this.evaluate(resolveTargetJs(ref));
81
+ if (!resolution.ok) {
82
+ throw new TargetError(resolution);
83
+ }
84
+ // Phase 2: Scroll to resolved element
85
+ return this.evaluate(scrollResolvedJs());
47
86
  }
48
87
  async getFormState() {
49
88
  return (await this.evaluate(getFormStateJs()));
@@ -18,6 +18,8 @@ export declare class BrowserBridge implements IBrowserFactory {
18
18
  }): Promise<IPage>;
19
19
  close(): Promise<void>;
20
20
  private _ensureDaemon;
21
+ /** Poll until daemon is fully stopped (port released). */
22
+ private _waitForDaemonStop;
21
23
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
22
24
  private _pollUntilReady;
23
25
  }
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { Page } from './page.js';
9
- import { getDaemonHealth } from './daemon-client.js';
9
+ import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
11
  import { BrowserConnectError } from '../errors.js';
12
+ import { PKG_VERSION } from '../version.js';
12
13
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
13
14
  /**
14
15
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -57,18 +58,42 @@ export class BrowserBridge {
57
58
  // Fast path: everything ready
58
59
  if (health.state === 'ready')
59
60
  return;
60
- // Daemon running but no extension — wait for extension with progress
61
+ // Daemon running but no extension
61
62
  if (health.state === 'no-extension') {
62
- if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
63
- process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
64
- process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
63
+ // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
64
+ const daemonVersion = health.status?.daemonVersion;
65
+ const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
66
+ if (isStale) {
67
+ // Stale daemon — restart it so extension gets a fresh WebSocket endpoint
68
+ const reason = daemonVersion
69
+ ? `v${daemonVersion} ≠ v${PKG_VERSION}`
70
+ : `pre-version daemon, CLI is v${PKG_VERSION}`;
71
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
72
+ process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
73
+ }
74
+ const shutdownAccepted = await requestDaemonShutdown();
75
+ const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
76
+ if (!portReleased) {
77
+ // Stale daemon replacement failed — don't blindly spawn on an occupied port
78
+ throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
79
+ ' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
80
+ }
81
+ // Port released — fall through to spawn a fresh daemon
82
+ }
83
+ else {
84
+ // Same version — wait for extension to connect
85
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
86
+ process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
87
+ process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
88
+ }
89
+ if (await this._pollUntilReady(timeoutMs))
90
+ return;
91
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
92
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
93
+ 'If not installed:\n' +
94
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
95
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
65
96
  }
66
- if (await this._pollUntilReady(timeoutMs))
67
- return;
68
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
69
- ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
70
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
71
- ' Then run: opencli doctor', 'extension-not-connected');
72
97
  }
73
98
  // No daemon — spawn one
74
99
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -94,13 +119,25 @@ export class BrowserBridge {
94
119
  return;
95
120
  const finalHealth = await getDaemonHealth();
96
121
  if (finalHealth.state === 'no-extension') {
97
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
122
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
123
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
124
+ 'If not installed:\n' +
98
125
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
99
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
100
- ' Then run: opencli doctor', 'extension-not-connected');
126
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
101
127
  }
102
128
  throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
103
129
  }
130
+ /** Poll until daemon is fully stopped (port released). */
131
+ async _waitForDaemonStop(timeoutMs) {
132
+ const deadline = Date.now() + timeoutMs;
133
+ while (Date.now() < deadline) {
134
+ await new Promise(resolve => setTimeout(resolve, 200));
135
+ const h = await getDaemonHealth();
136
+ if (h.state === 'stopped')
137
+ return true;
138
+ }
139
+ return false;
140
+ }
104
141
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
105
142
  async _pollUntilReady(timeoutMs) {
106
143
  const deadline = Date.now() + timeoutMs;
@@ -40,7 +40,11 @@ export class CDPBridge {
40
40
  return new Promise((resolve, reject) => {
41
41
  const ws = new WebSocket(wsUrl);
42
42
  const timeoutMs = (opts?.timeout ?? 10) * 1000;
43
- const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
43
+ const timeout = setTimeout(() => {
44
+ this._ws = null;
45
+ ws.close();
46
+ reject(new Error('CDP connect timeout'));
47
+ }, timeoutMs);
44
48
  ws.on('open', async () => {
45
49
  clearTimeout(timeout);
46
50
  this._ws = ws;
@@ -48,7 +52,11 @@ export class CDPBridge {
48
52
  await this.send('Page.enable');
49
53
  await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
50
54
  }
51
- catch { }
55
+ catch (err) {
56
+ ws.close();
57
+ reject(err instanceof Error ? err : new Error(String(err)));
58
+ return;
59
+ }
52
60
  resolve(new CDPPage(this));
53
61
  });
54
62
  ws.on('error', (err) => {
@@ -247,6 +255,7 @@ class CDPPage extends BasePage {
247
255
  });
248
256
  this._networkCapturing = true;
249
257
  }
258
+ return true;
250
259
  }
251
260
  async readNetworkCapture() {
252
261
  // Await all in-flight body fetches so entries have responsePreview populated
@@ -47,8 +47,10 @@ export interface DaemonStatus {
47
47
  ok: boolean;
48
48
  pid: number;
49
49
  uptime: number;
50
+ daemonVersion?: string;
50
51
  extensionConnected: boolean;
51
52
  extensionVersion?: string;
53
+ extensionCompatRange?: string;
52
54
  pending: number;
53
55
  memoryMB: number;
54
56
  port: number;
@@ -575,6 +575,7 @@ export function generateSnapshotJs(opts = {}) {
575
575
  const lines = [];
576
576
  const hiddenInteractives = [];
577
577
  const currentHashes = [];
578
+ const refIdentity = {};
578
579
  let iframeCount = 0;
579
580
 
580
581
  function walk(el, depth, parentPropagatingRect) {
@@ -709,11 +710,20 @@ export function generateSnapshotJs(opts = {}) {
709
710
  // Scroll marker
710
711
  if (isScrollable && !interactive) line += '|scroll|';
711
712
 
712
- // Interactive index + data-ref
713
+ // Interactive index + data-ref + fingerprint
713
714
  if (interactive) {
714
715
  interactiveIndex++;
715
716
  if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
716
717
  line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']';
718
+ // Store fingerprint for stale-ref detection
719
+ refIdentity['' + interactiveIndex] = {
720
+ tag: tag,
721
+ role: el.getAttribute('role') || '',
722
+ text: (el.textContent || '').trim().slice(0, 30),
723
+ ariaLabel: el.getAttribute('aria-label') || '',
724
+ id: el.id || '',
725
+ testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
726
+ };
717
727
  }
718
728
 
719
729
  // Tag + attributes
@@ -797,6 +807,8 @@ export function generateSnapshotJs(opts = {}) {
797
807
 
798
808
  // Store hashes on window for next diff snapshot
799
809
  try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {}
810
+ // Store ref identity map for stale-ref detection by target resolver
811
+ try { window.__opencli_ref_identity = refIdentity; } catch {}
800
812
 
801
813
  return lines.join('\\n');
802
814
  })()
@@ -18,6 +18,8 @@ export declare class Page extends BasePage {
18
18
  constructor(workspace?: string);
19
19
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
20
20
  private _page;
21
+ private _networkCaptureUnsupported;
22
+ private _networkCaptureWarned;
21
23
  /** Helper: spread workspace into command params */
22
24
  private _wsOpt;
23
25
  /** Helper: spread workspace + page identity into command params */
@@ -30,6 +32,7 @@ export declare class Page extends BasePage {
30
32
  getActivePage(): string | undefined;
31
33
  /** @deprecated Use getActivePage() instead */
32
34
  getActiveTabId(): number | undefined;
35
+ private _markUnsupportedNetworkCapture;
33
36
  evaluate(js: string): Promise<unknown>;
34
37
  getCookies(opts?: {
35
38
  domain?: string;
@@ -43,7 +46,7 @@ export declare class Page extends BasePage {
43
46
  * Capture a screenshot via CDP Page.captureScreenshot.
44
47
  */
45
48
  screenshot(options?: ScreenshotOptions): Promise<string>;
46
- startNetworkCapture(pattern?: string): Promise<void>;
49
+ startNetworkCapture(pattern?: string): Promise<boolean>;
47
50
  readNetworkCapture(): Promise<unknown[]>;
48
51
  /**
49
52
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
@@ -15,6 +15,13 @@ import { generateStealthJs } from './stealth.js';
15
15
  import { waitForDomStableJs } from './dom-helpers.js';
16
16
  import { BasePage } from './base-page.js';
17
17
  import { classifyBrowserError } from './errors.js';
18
+ import { log } from '../logger.js';
19
+ function isUnsupportedNetworkCaptureError(err) {
20
+ const message = err instanceof Error ? err.message : String(err);
21
+ const normalized = message.toLowerCase();
22
+ return (normalized.includes('unknown action') && normalized.includes('network-capture'))
23
+ || (normalized.includes('network capture') && normalized.includes('not supported'));
24
+ }
18
25
  /**
19
26
  * Page — implements IPage by talking to the daemon via HTTP.
20
27
  */
@@ -26,6 +33,8 @@ export class Page extends BasePage {
26
33
  }
27
34
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
28
35
  _page;
36
+ _networkCaptureUnsupported = false;
37
+ _networkCaptureWarned = false;
29
38
  /** Helper: spread workspace into command params */
30
39
  _wsOpt() {
31
40
  return { workspace: this.workspace };
@@ -97,6 +106,14 @@ export class Page extends BasePage {
97
106
  getActiveTabId() {
98
107
  return undefined;
99
108
  }
109
+ _markUnsupportedNetworkCapture() {
110
+ this._networkCaptureUnsupported = true;
111
+ if (this._networkCaptureWarned)
112
+ return;
113
+ this._networkCaptureWarned = true;
114
+ log.warn('Browser Bridge extension does not support network capture; continuing without it. ' +
115
+ 'Explore output may miss API endpoints until you reload or reinstall the extension.');
116
+ }
100
117
  async evaluate(js) {
101
118
  const code = wrapForEval(js);
102
119
  try {
@@ -125,6 +142,8 @@ export class Page extends BasePage {
125
142
  finally {
126
143
  this._page = undefined;
127
144
  this._lastUrl = null;
145
+ this._networkCaptureUnsupported = false;
146
+ this._networkCaptureWarned = false;
128
147
  }
129
148
  }
130
149
  async tabs() {
@@ -152,16 +171,37 @@ export class Page extends BasePage {
152
171
  return base64;
153
172
  }
154
173
  async startNetworkCapture(pattern = '') {
155
- await sendCommand('network-capture-start', {
156
- pattern,
157
- ...this._cmdOpts(),
158
- });
174
+ if (this._networkCaptureUnsupported)
175
+ return false;
176
+ try {
177
+ await sendCommand('network-capture-start', {
178
+ pattern,
179
+ ...this._cmdOpts(),
180
+ });
181
+ return true;
182
+ }
183
+ catch (err) {
184
+ if (!isUnsupportedNetworkCaptureError(err))
185
+ throw err;
186
+ this._markUnsupportedNetworkCapture();
187
+ return false;
188
+ }
159
189
  }
160
190
  async readNetworkCapture() {
161
- const result = await sendCommand('network-capture-read', {
162
- ...this._cmdOpts(),
163
- });
164
- return Array.isArray(result) ? result : [];
191
+ if (this._networkCaptureUnsupported)
192
+ return [];
193
+ try {
194
+ const result = await sendCommand('network-capture-read', {
195
+ ...this._cmdOpts(),
196
+ });
197
+ return Array.isArray(result) ? result : [];
198
+ }
199
+ catch (err) {
200
+ if (!isUnsupportedNetworkCaptureError(err))
201
+ throw err;
202
+ this._markUnsupportedNetworkCapture();
203
+ return [];
204
+ }
165
205
  }
166
206
  /**
167
207
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
@@ -1,14 +1,26 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- const { sendCommandMock } = vi.hoisted(() => ({
2
+ const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
3
3
  sendCommandMock: vi.fn(),
4
+ sendCommandFullMock: vi.fn(),
5
+ }));
6
+ const { warnMock } = vi.hoisted(() => ({
7
+ warnMock: vi.fn(),
4
8
  }));
5
9
  vi.mock('./daemon-client.js', () => ({
6
10
  sendCommand: sendCommandMock,
11
+ sendCommandFull: sendCommandFullMock,
12
+ }));
13
+ vi.mock('../logger.js', () => ({
14
+ log: {
15
+ warn: warnMock,
16
+ },
7
17
  }));
8
18
  import { Page } from './page.js';
9
19
  describe('Page.getCurrentUrl', () => {
10
20
  beforeEach(() => {
11
21
  sendCommandMock.mockReset();
22
+ sendCommandFullMock.mockReset();
23
+ warnMock.mockReset();
12
24
  });
13
25
  it('reads the real browser URL when no local navigation cache exists', async () => {
14
26
  sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
@@ -31,6 +43,8 @@ describe('Page.getCurrentUrl', () => {
31
43
  describe('Page.evaluate', () => {
32
44
  beforeEach(() => {
33
45
  sendCommandMock.mockReset();
46
+ sendCommandFullMock.mockReset();
47
+ warnMock.mockReset();
34
48
  });
35
49
  it('retries once when the inspected target navigated during exec', async () => {
36
50
  sendCommandMock
@@ -42,3 +56,49 @@ describe('Page.evaluate', () => {
42
56
  expect(sendCommandMock).toHaveBeenCalledTimes(2);
43
57
  });
44
58
  });
59
+ describe('Page network capture compatibility', () => {
60
+ beforeEach(() => {
61
+ sendCommandMock.mockReset();
62
+ sendCommandFullMock.mockReset();
63
+ warnMock.mockReset();
64
+ });
65
+ it('treats unknown network-capture-start as unsupported and memoizes it', async () => {
66
+ sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'));
67
+ const page = new Page('site:notebooklm');
68
+ await expect(page.startNetworkCapture()).resolves.toBe(false);
69
+ await expect(page.startNetworkCapture()).resolves.toBe(false);
70
+ expect(sendCommandMock).toHaveBeenCalledTimes(1);
71
+ expect(warnMock).toHaveBeenCalledTimes(1);
72
+ expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture'));
73
+ expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({
74
+ workspace: 'site:notebooklm',
75
+ }));
76
+ });
77
+ it('returns an empty capture when network-capture-read is unsupported', async () => {
78
+ sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
79
+ const page = new Page('site:notebooklm');
80
+ await expect(page.readNetworkCapture()).resolves.toEqual([]);
81
+ await expect(page.readNetworkCapture()).resolves.toEqual([]);
82
+ expect(sendCommandMock).toHaveBeenCalledTimes(1);
83
+ expect(warnMock).toHaveBeenCalledTimes(1);
84
+ expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({
85
+ workspace: 'site:notebooklm',
86
+ }));
87
+ });
88
+ it('rethrows unrelated network capture failures', async () => {
89
+ sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected'));
90
+ const page = new Page('site:notebooklm');
91
+ await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected');
92
+ expect(sendCommandMock).toHaveBeenCalledTimes(1);
93
+ expect(warnMock).not.toHaveBeenCalled();
94
+ });
95
+ it('warns only once even if both start and read hit the compatibility fallback', async () => {
96
+ sendCommandMock
97
+ .mockRejectedValueOnce(new Error('Unknown action: network-capture-start'))
98
+ .mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
99
+ const page = new Page('site:notebooklm');
100
+ await expect(page.startNetworkCapture()).resolves.toBe(false);
101
+ await expect(page.readNetworkCapture()).resolves.toEqual([]);
102
+ expect(warnMock).toHaveBeenCalledTimes(1);
103
+ });
104
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Structured error types for the target resolution system.
3
+ *
4
+ * Every browser action (click, type, select, get) that targets a DOM element
5
+ * goes through the unified resolver. When resolution fails, one of these
6
+ * structured errors is thrown so that AI agents and adapter authors get
7
+ * actionable diagnostics instead of a generic "Element not found".
8
+ */
9
+ export type TargetErrorCode = 'not_found' | 'ambiguous' | 'stale_ref';
10
+ export interface TargetErrorInfo {
11
+ code: TargetErrorCode;
12
+ message: string;
13
+ hint: string;
14
+ candidates?: string[];
15
+ }
16
+ export declare class TargetError extends Error {
17
+ readonly code: TargetErrorCode;
18
+ readonly hint: string;
19
+ readonly candidates?: string[];
20
+ constructor(info: TargetErrorInfo);
21
+ /** Serialize for structured output to AI agents */
22
+ toJSON(): TargetErrorInfo;
23
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Structured error types for the target resolution system.
3
+ *
4
+ * Every browser action (click, type, select, get) that targets a DOM element
5
+ * goes through the unified resolver. When resolution fails, one of these
6
+ * structured errors is thrown so that AI agents and adapter authors get
7
+ * actionable diagnostics instead of a generic "Element not found".
8
+ */
9
+ export class TargetError extends Error {
10
+ code;
11
+ hint;
12
+ candidates;
13
+ constructor(info) {
14
+ super(info.message);
15
+ this.name = 'TargetError';
16
+ this.code = info.code;
17
+ this.hint = info.hint;
18
+ this.candidates = info.candidates;
19
+ }
20
+ /** Serialize for structured output to AI agents */
21
+ toJSON() {
22
+ return {
23
+ code: this.code,
24
+ message: this.message,
25
+ hint: this.hint,
26
+ ...(this.candidates && { candidates: this.candidates }),
27
+ };
28
+ }
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TargetError } from './target-errors.js';
3
+ describe('TargetError', () => {
4
+ it('creates not_found error with code and hint', () => {
5
+ const err = new TargetError({
6
+ code: 'not_found',
7
+ message: 'ref=99 not found in DOM',
8
+ hint: 'Re-run `opencli browser state` to get a fresh snapshot.',
9
+ });
10
+ expect(err).toBeInstanceOf(Error);
11
+ expect(err.name).toBe('TargetError');
12
+ expect(err.code).toBe('not_found');
13
+ expect(err.message).toBe('ref=99 not found in DOM');
14
+ expect(err.hint).toContain('fresh snapshot');
15
+ expect(err.candidates).toBeUndefined();
16
+ });
17
+ it('creates ambiguous error with candidates', () => {
18
+ const err = new TargetError({
19
+ code: 'ambiguous',
20
+ message: 'CSS selector ".btn" matched 3 elements',
21
+ hint: 'Use a more specific selector.',
22
+ candidates: ['<button> "Login"', '<button> "Sign Up"', '<button> "Cancel"'],
23
+ });
24
+ expect(err.code).toBe('ambiguous');
25
+ expect(err.candidates).toHaveLength(3);
26
+ expect(err.candidates[0]).toContain('Login');
27
+ });
28
+ it('creates stale_ref error', () => {
29
+ const err = new TargetError({
30
+ code: 'stale_ref',
31
+ message: 'ref=12 was <button>"Login" but now points to <div>"Header"',
32
+ hint: 'Re-run `opencli browser state` to refresh.',
33
+ });
34
+ expect(err.code).toBe('stale_ref');
35
+ expect(err.message).toContain('was <button>');
36
+ });
37
+ it('serializes to JSON for structured output', () => {
38
+ const err = new TargetError({
39
+ code: 'ambiguous',
40
+ message: 'matched 3',
41
+ hint: 'be specific',
42
+ candidates: ['a', 'b'],
43
+ });
44
+ const json = err.toJSON();
45
+ expect(json).toEqual({
46
+ code: 'ambiguous',
47
+ message: 'matched 3',
48
+ hint: 'be specific',
49
+ candidates: ['a', 'b'],
50
+ });
51
+ });
52
+ it('omits candidates from JSON when not present', () => {
53
+ const err = new TargetError({
54
+ code: 'not_found',
55
+ message: 'gone',
56
+ hint: 'refresh',
57
+ });
58
+ const json = err.toJSON();
59
+ expect(json).not.toHaveProperty('candidates');
60
+ });
61
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unified target resolver for browser actions.
3
+ *
4
+ * Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
5
+ * principled resolution pipeline:
6
+ *
7
+ * 1. Input classification: numeric → ref path, CSS-like → CSS path
8
+ * 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
9
+ * 3. CSS path: querySelectorAll + uniqueness check
10
+ * 4. Structured errors: stale_ref / ambiguous / not_found
11
+ *
12
+ * All JS is generated as strings for page.evaluate() — runs in the browser.
13
+ */
14
+ /**
15
+ * Generate JS that resolves a target to a single DOM element.
16
+ *
17
+ * Returns a JS expression that evaluates to:
18
+ * { ok: true, el: Element } — success (el is assigned to `__resolved`)
19
+ * { ok: false, code, message, hint, candidates } — structured error
20
+ *
21
+ * The resolved element is stored in `__resolved` for the caller to use.
22
+ */
23
+ export declare function resolveTargetJs(ref: string): string;
24
+ /**
25
+ * Generate JS for click that uses the unified resolver.
26
+ * Assumes resolveTargetJs has been called and __resolved is set.
27
+ */
28
+ export declare function clickResolvedJs(): string;
29
+ /**
30
+ * Generate JS for type that uses the unified resolver.
31
+ */
32
+ export declare function typeResolvedJs(text: string): string;
33
+ /**
34
+ * Generate JS for scrollTo that uses the unified resolver.
35
+ * Assumes resolveTargetJs has been called and __resolved is set.
36
+ */
37
+ export declare function scrollResolvedJs(): string;
38
+ /**
39
+ * Generate JS to get text content of resolved element.
40
+ */
41
+ export declare function getTextResolvedJs(): string;
42
+ /**
43
+ * Generate JS to get value of resolved input/textarea element.
44
+ */
45
+ export declare function getValueResolvedJs(): string;
46
+ /**
47
+ * Generate JS to get all attributes of resolved element.
48
+ */
49
+ export declare function getAttributesResolvedJs(): string;
50
+ /**
51
+ * Generate JS to select an option on a resolved <select> element.
52
+ */
53
+ export declare function selectResolvedJs(option: string): string;
54
+ /**
55
+ * Generate JS to check if resolved element is an autocomplete/combobox field.
56
+ */
57
+ export declare function isAutocompleteResolvedJs(): string;