@jackwener/opencli 1.6.8 → 1.6.9

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 (84) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -1
  3. package/dist/clis/jianyu/search.d.ts +14 -0
  4. package/dist/clis/jianyu/search.js +135 -0
  5. package/dist/clis/jianyu/search.test.d.ts +1 -0
  6. package/dist/clis/jianyu/search.test.js +23 -0
  7. package/dist/clis/quark/ls.d.ts +1 -0
  8. package/dist/clis/quark/ls.js +63 -0
  9. package/dist/clis/quark/mkdir.d.ts +1 -0
  10. package/dist/clis/quark/mkdir.js +36 -0
  11. package/dist/clis/quark/mv.d.ts +1 -0
  12. package/dist/clis/quark/mv.js +53 -0
  13. package/dist/clis/quark/rename.d.ts +1 -0
  14. package/dist/clis/quark/rename.js +26 -0
  15. package/dist/clis/quark/rm.d.ts +1 -0
  16. package/dist/clis/quark/rm.js +24 -0
  17. package/dist/clis/quark/save.d.ts +1 -0
  18. package/dist/clis/quark/save.js +80 -0
  19. package/dist/clis/quark/share-tree.d.ts +1 -0
  20. package/dist/clis/quark/share-tree.js +45 -0
  21. package/dist/clis/quark/utils.d.ts +50 -0
  22. package/dist/clis/quark/utils.js +146 -0
  23. package/dist/clis/quark/utils.test.d.ts +1 -0
  24. package/dist/clis/quark/utils.test.js +58 -0
  25. package/dist/clis/twitter/reply.js +3 -8
  26. package/dist/clis/twitter/reply.test.js +5 -5
  27. package/dist/clis/xiaohongshu/note.js +8 -3
  28. package/dist/clis/xiaohongshu/note.test.js +11 -0
  29. package/dist/clis/zhihu/answer.d.ts +1 -0
  30. package/dist/clis/zhihu/answer.js +194 -0
  31. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  32. package/dist/clis/zhihu/answer.test.js +81 -0
  33. package/dist/clis/zhihu/comment.d.ts +1 -0
  34. package/dist/clis/zhihu/comment.js +335 -0
  35. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  36. package/dist/clis/zhihu/comment.test.js +54 -0
  37. package/dist/clis/zhihu/favorite.d.ts +1 -0
  38. package/dist/clis/zhihu/favorite.js +224 -0
  39. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  40. package/dist/clis/zhihu/favorite.test.js +196 -0
  41. package/dist/clis/zhihu/follow.d.ts +1 -0
  42. package/dist/clis/zhihu/follow.js +80 -0
  43. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  44. package/dist/clis/zhihu/follow.test.js +45 -0
  45. package/dist/clis/zhihu/like.d.ts +1 -0
  46. package/dist/clis/zhihu/like.js +91 -0
  47. package/dist/clis/zhihu/like.test.d.ts +1 -0
  48. package/dist/clis/zhihu/like.test.js +64 -0
  49. package/dist/clis/zhihu/target.d.ts +24 -0
  50. package/dist/clis/zhihu/target.js +91 -0
  51. package/dist/clis/zhihu/target.test.d.ts +1 -0
  52. package/dist/clis/zhihu/target.test.js +77 -0
  53. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  54. package/dist/clis/zhihu/write-shared.js +221 -0
  55. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  56. package/dist/clis/zhihu/write-shared.test.js +175 -0
  57. package/dist/src/browser/bridge.d.ts +2 -0
  58. package/dist/src/browser/bridge.js +30 -24
  59. package/dist/src/browser/daemon-client.d.ts +17 -8
  60. package/dist/src/browser/daemon-client.js +12 -13
  61. package/dist/src/browser/daemon-client.test.js +32 -25
  62. package/dist/src/browser/index.d.ts +2 -1
  63. package/dist/src/browser/index.js +1 -1
  64. package/dist/src/browser.test.js +2 -3
  65. package/dist/src/cli.js +3 -3
  66. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  67. package/dist/src/clis/binance/commands.test.js +54 -0
  68. package/dist/src/commanderAdapter.js +19 -6
  69. package/dist/src/diagnostic.d.ts +1 -0
  70. package/dist/src/diagnostic.js +64 -2
  71. package/dist/src/diagnostic.test.js +91 -1
  72. package/dist/src/doctor.d.ts +2 -0
  73. package/dist/src/doctor.js +59 -31
  74. package/dist/src/doctor.test.js +89 -16
  75. package/dist/src/execution.js +1 -13
  76. package/dist/src/explore.js +1 -1
  77. package/dist/src/generate.d.ts +2 -5
  78. package/dist/src/generate.js +2 -5
  79. package/dist/src/plugin.d.ts +2 -1
  80. package/dist/src/plugin.js +25 -8
  81. package/dist/src/plugin.test.js +16 -1
  82. package/package.json +3 -3
  83. package/dist/src/browser/discover.d.ts +0 -15
  84. package/dist/src/browser/discover.js +0 -19
@@ -39,6 +39,18 @@ export async function fetchDaemonStatus(opts) {
39
39
  return null;
40
40
  }
41
41
  }
42
+ /**
43
+ * Unified daemon health check — single entry point for all status queries.
44
+ * Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
45
+ */
46
+ export async function getDaemonHealth(opts) {
47
+ const status = await fetchDaemonStatus(opts);
48
+ if (!status)
49
+ return { state: 'stopped', status: null };
50
+ if (!status.extensionConnected)
51
+ return { state: 'no-extension', status };
52
+ return { state: 'ready', status };
53
+ }
42
54
  export async function requestDaemonShutdown(opts) {
43
55
  try {
44
56
  const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
@@ -48,19 +60,6 @@ export async function requestDaemonShutdown(opts) {
48
60
  return false;
49
61
  }
50
62
  }
51
- /**
52
- * Check if daemon is running.
53
- */
54
- export async function isDaemonRunning() {
55
- return (await fetchDaemonStatus()) !== null;
56
- }
57
- /**
58
- * Check if daemon is running AND the extension is connected.
59
- */
60
- export async function isExtensionConnected() {
61
- const status = await fetchDaemonStatus();
62
- return !!status?.extensionConnected;
63
- }
64
63
  /**
65
64
  * Send a command to the daemon and wait for a result.
66
65
  * Retries up to 4 times: network errors retry at 500ms,
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchDaemonStatus, isDaemonRunning, isExtensionConnected, requestDaemonShutdown, } from './daemon-client.js';
2
+ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
3
3
  describe('daemon-client', () => {
4
4
  beforeEach(() => {
5
5
  vi.stubGlobal('fetch', vi.fn());
@@ -42,36 +42,43 @@ describe('daemon-client', () => {
42
42
  headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
43
43
  }));
44
44
  });
45
- it('isDaemonRunning reflects shared status availability', async () => {
45
+ it('getDaemonHealth returns stopped when daemon is not reachable', async () => {
46
+ vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
47
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null });
48
+ });
49
+ it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => {
50
+ const status = {
51
+ ok: true,
52
+ pid: 123,
53
+ uptime: 10,
54
+ extensionConnected: false,
55
+ pending: 0,
56
+ lastCliRequestTime: Date.now(),
57
+ memoryMB: 16,
58
+ port: 19825,
59
+ };
46
60
  vi.mocked(fetch).mockResolvedValue({
47
61
  ok: true,
48
- json: () => Promise.resolve({
49
- ok: true,
50
- pid: 123,
51
- uptime: 10,
52
- extensionConnected: false,
53
- pending: 0,
54
- lastCliRequestTime: Date.now(),
55
- memoryMB: 16,
56
- port: 19825,
57
- }),
62
+ json: () => Promise.resolve(status),
58
63
  });
59
- await expect(isDaemonRunning()).resolves.toBe(true);
64
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status });
60
65
  });
61
- it('isExtensionConnected reflects shared status payload', async () => {
66
+ it('getDaemonHealth returns ready when daemon and extension are both connected', async () => {
67
+ const status = {
68
+ ok: true,
69
+ pid: 123,
70
+ uptime: 10,
71
+ extensionConnected: true,
72
+ extensionVersion: '1.2.3',
73
+ pending: 0,
74
+ lastCliRequestTime: Date.now(),
75
+ memoryMB: 32,
76
+ port: 19825,
77
+ };
62
78
  vi.mocked(fetch).mockResolvedValue({
63
79
  ok: true,
64
- json: () => Promise.resolve({
65
- ok: true,
66
- pid: 123,
67
- uptime: 10,
68
- extensionConnected: false,
69
- pending: 0,
70
- lastCliRequestTime: Date.now(),
71
- memoryMB: 16,
72
- port: 19825,
73
- }),
80
+ json: () => Promise.resolve(status),
74
81
  });
75
- await expect(isExtensionConnected()).resolves.toBe(false);
82
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
76
83
  });
77
84
  });
@@ -7,7 +7,8 @@
7
7
  export { Page } from './page.js';
8
8
  export { BrowserBridge } from './bridge.js';
9
9
  export { CDPBridge } from './cdp.js';
10
- export { isDaemonRunning } from './daemon-client.js';
10
+ export { getDaemonHealth } from './daemon-client.js';
11
+ export type { DaemonHealth } from './daemon-client.js';
11
12
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
13
  export { generateStealthJs } from './stealth.js';
13
14
  export type { DomSnapshotOptions } from './dom-snapshot.js';
@@ -7,6 +7,6 @@
7
7
  export { Page } from './page.js';
8
8
  export { BrowserBridge } from './bridge.js';
9
9
  export { CDPBridge } from './cdp.js';
10
- export { isDaemonRunning } from './daemon-client.js';
10
+ export { getDaemonHealth } from './daemon-client.js';
11
11
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
12
  export { generateStealthJs } from './stealth.js';
@@ -105,10 +105,9 @@ describe('BrowserBridge state', () => {
105
105
  await expect(bridge.connect()).rejects.toThrow('Session is closing');
106
106
  });
107
107
  it('fails fast when daemon is running but extension is disconnected', async () => {
108
- vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
109
- vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false });
108
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { extensionConnected: false } });
110
109
  const bridge = new BrowserBridge();
111
- await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
110
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
112
111
  });
113
112
  });
114
113
  describe('stealth anti-detection', () => {
package/dist/src/cli.js CHANGED
@@ -270,11 +270,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
270
270
  operate.command('open').argument('<url>').description('Open URL in automation window')
271
271
  .action(operateAction(async (page, url) => {
272
272
  // Start session-level capture before navigation (catches initial requests)
273
- await page.startNetworkCapture?.();
273
+ const hasSessionCapture = await page.startNetworkCapture?.().then(() => true).catch(() => false);
274
274
  await page.goto(url);
275
275
  await page.wait(2);
276
- // Fallback: also inject JS interceptor for pages without session capture
277
- if (!page.startNetworkCapture) {
276
+ // Fallback: inject JS interceptor when session capture is unavailable
277
+ if (!hasSessionCapture) {
278
278
  try {
279
279
  await page.evaluate(NETWORK_INTERCEPTOR_JS);
280
280
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import yaml from 'js-yaml';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { executePipeline } from '../../pipeline/index.js';
5
+ function loadPipeline(name) {
6
+ const file = new URL(`./${name}.yaml`, import.meta.url);
7
+ const def = yaml.load(fs.readFileSync(file, 'utf-8'));
8
+ return def.pipeline;
9
+ }
10
+ function mockJsonOnce(payload) {
11
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
12
+ ok: true,
13
+ status: 200,
14
+ statusText: 'OK',
15
+ json: vi.fn().mockResolvedValue(payload),
16
+ }));
17
+ }
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals();
20
+ vi.restoreAllMocks();
21
+ });
22
+ describe('binance YAML adapters', () => {
23
+ it('sorts top pairs by numeric quote volume', async () => {
24
+ mockJsonOnce([
25
+ { symbol: 'SMALL', lastPrice: '1', priceChangePercent: '1.2', highPrice: '1', lowPrice: '1', quoteVolume: '9.9' },
26
+ { symbol: 'LARGE', lastPrice: '2', priceChangePercent: '2.3', highPrice: '2', lowPrice: '2', quoteVolume: '100.0' },
27
+ { symbol: 'MID', lastPrice: '3', priceChangePercent: '3.4', highPrice: '3', lowPrice: '3', quoteVolume: '11.0' },
28
+ ]);
29
+ const result = await executePipeline(null, loadPipeline('top'), { args: { limit: 3 } });
30
+ expect(result.map((item) => item.symbol)).toEqual(['LARGE', 'MID', 'SMALL']);
31
+ expect(result.map((item) => item.rank)).toEqual([1, 2, 3]);
32
+ });
33
+ it('sorts gainers by numeric percent change', async () => {
34
+ mockJsonOnce([
35
+ { symbol: 'TEN', lastPrice: '1', priceChangePercent: '10.0', quoteVolume: '100' },
36
+ { symbol: 'NINE', lastPrice: '1', priceChangePercent: '9.5', quoteVolume: '100' },
37
+ { symbol: 'HUNDRED', lastPrice: '1', priceChangePercent: '100.0', quoteVolume: '100' },
38
+ ]);
39
+ const result = await executePipeline(null, loadPipeline('gainers'), { args: { limit: 3 } });
40
+ expect(result.map((item) => item.symbol)).toEqual(['HUNDRED', 'TEN', 'NINE']);
41
+ });
42
+ it('keeps only TRADING pairs', async () => {
43
+ mockJsonOnce({
44
+ symbols: [
45
+ { symbol: 'BTCUSDT', baseAsset: 'BTC', quoteAsset: 'USDT', status: 'TRADING' },
46
+ { symbol: 'OLDPAIR', baseAsset: 'OLD', quoteAsset: 'USDT', status: 'BREAK' },
47
+ ],
48
+ });
49
+ const result = await executePipeline(null, loadPipeline('pairs'), { args: { limit: 10 } });
50
+ expect(result).toEqual([
51
+ { symbol: 'BTCUSDT', base: 'BTC', quote: 'USDT', status: 'TRADING' },
52
+ ]);
53
+ });
54
+ });
@@ -15,7 +15,8 @@ import { formatRegistryHelpText } from './serialization.js';
15
15
  import { render as renderOutput } from './output.js';
16
16
  import { executeCommand } from './execution.js';
17
17
  import { CliError, EXIT_CODES, ERROR_ICONS, getErrorMessage, BrowserConnectError, AuthRequiredError, TimeoutError, SelectorError, EmptyResultError, ArgumentError, AdapterLoadError, CommandExecutionError, } from './errors.js';
18
- import { checkDaemonStatus } from './browser/discover.js';
18
+ import { getDaemonHealth } from './browser/daemon-client.js';
19
+ import { isDiagnosticEnabled } from './diagnostic.js';
19
20
  export function normalizeArgValue(argType, value, name) {
20
21
  if (argType !== 'bool' && argType !== 'boolean')
21
22
  return value;
@@ -169,18 +170,27 @@ function renderBridgeStatus(running, extensionConnected) {
169
170
  console.error(chalk.dim(' Try reloading the extension, or run: opencli doctor'));
170
171
  }
171
172
  }
173
+ /** Emit AutoFix hint for repairable adapter errors (skipped if already in diagnostic mode). */
174
+ function emitAutoFixHint(cmdName) {
175
+ if (isDiagnosticEnabled())
176
+ return; // Already collecting diagnostics, don't repeat
177
+ console.error();
178
+ console.error(chalk.cyan('💡 AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context.'));
179
+ console.error(chalk.dim(` OPENCLI_DIAGNOSTIC=1 ${cmdName}`));
180
+ }
172
181
  async function renderError(err, cmdName, verbose) {
173
182
  // ── BrowserConnectError: real-time diagnosis, kind as fallback ────────
174
183
  if (err instanceof BrowserConnectError) {
175
- console.error(chalk.red('🔌 Browser Bridge not connected'));
184
+ console.error(chalk.red(`🔌 ${err.message}`));
185
+ if (err.hint)
186
+ console.error(chalk.yellow(`→ ${err.hint}`));
176
187
  console.error();
177
188
  try {
178
- // 300ms matches execution.ts localhost responds in <50ms when running.
179
- const status = await checkDaemonStatus({ timeout: 300 });
180
- renderBridgeStatus(status.running, status.extensionConnected);
189
+ const health = await getDaemonHealth({ timeout: 300 });
190
+ renderBridgeStatus(health.state !== 'stopped', health.state === 'ready');
181
191
  }
182
192
  catch (_statusErr) {
183
- // checkDaemonStatus itself failed — derive best-guess state from kind.
193
+ // getDaemonHealth itself failed — derive best-guess state from kind.
184
194
  const running = err.kind !== 'daemon-not-running';
185
195
  const extensionConnected = err.kind === 'command-failed';
186
196
  renderBridgeStatus(running, extensionConnected);
@@ -208,6 +218,7 @@ async function renderError(err, cmdName, verbose) {
208
218
  console.error(chalk.yellow(`→ ${err.hint ?? 'The page structure may have changed — this adapter may be outdated.'}`));
209
219
  console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
210
220
  console.error(chalk.dim(` Report: ${ISSUES_URL}`));
221
+ emitAutoFixHint(cmdName);
211
222
  return;
212
223
  }
213
224
  // ── ArgumentError ─────────────────────────────────────────────────────
@@ -251,6 +262,8 @@ async function renderError(err, cmdName, verbose) {
251
262
  console.error(chalk.yellow(`→ ${classified.hint}`));
252
263
  if (classified.kind === 'not-found')
253
264
  console.error(chalk.dim(` Report: ${ISSUES_URL}`));
265
+ if (classified.kind === 'not-found')
266
+ emitAutoFixHint(cmdName);
254
267
  return;
255
268
  }
256
269
  // ── Unknown error: show stack in verbose mode ─────────────────────────
@@ -32,6 +32,7 @@ export interface RepairContext {
32
32
  url: string;
33
33
  snapshot: string;
34
34
  networkRequests: unknown[];
35
+ capturedPayloads?: unknown[];
35
36
  consoleErrors: unknown[];
36
37
  };
37
38
  timestamp: string;
@@ -24,10 +24,16 @@ const MAX_SNAPSHOT_CHARS = 100_000;
24
24
  const MAX_SOURCE_CHARS = 50_000;
25
25
  /** Maximum number of network requests to include. */
26
26
  const MAX_NETWORK_REQUESTS = 50;
27
+ /** Maximum number of captured interceptor payloads to include. */
28
+ const MAX_CAPTURED_PAYLOADS = 20;
27
29
  /** Maximum characters for a single network request body. */
28
30
  const MAX_REQUEST_BODY_CHARS = 4_000;
29
31
  /** Maximum characters for error stack trace. */
30
32
  const MAX_STACK_CHARS = 5_000;
33
+ /** Maximum nesting depth for arbitrary captured payloads. */
34
+ const MAX_CAPTURED_DEPTH = 4;
35
+ /** Maximum object keys or array items to keep per nesting level. */
36
+ const MAX_CAPTURED_CHILDREN = 20;
31
37
  // ── Sensitive data patterns ──────────────────────────────────────────────────
32
38
  const SENSITIVE_HEADERS = new Set([
33
39
  'authorization',
@@ -82,6 +88,39 @@ function redactHeaders(headers) {
82
88
  }
83
89
  return result;
84
90
  }
91
+ /** Recursively sanitize arbitrary captured response content for diagnostic output. */
92
+ function sanitizeCapturedValue(value, depth = 0) {
93
+ if (typeof value === 'string') {
94
+ return redactText(truncate(value, MAX_REQUEST_BODY_CHARS));
95
+ }
96
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') {
97
+ return value;
98
+ }
99
+ if (depth >= MAX_CAPTURED_DEPTH) {
100
+ return '[truncated: max depth reached]';
101
+ }
102
+ if (Array.isArray(value)) {
103
+ const items = value
104
+ .slice(0, MAX_CAPTURED_CHILDREN)
105
+ .map(item => sanitizeCapturedValue(item, depth + 1));
106
+ if (value.length > MAX_CAPTURED_CHILDREN) {
107
+ items.push(`[truncated, ${value.length - MAX_CAPTURED_CHILDREN} items omitted]`);
108
+ }
109
+ return items;
110
+ }
111
+ if (!value || typeof value !== 'object') {
112
+ return value;
113
+ }
114
+ const entries = Object.entries(value);
115
+ const result = {};
116
+ for (const [key, child] of entries.slice(0, MAX_CAPTURED_CHILDREN)) {
117
+ result[key] = sanitizeCapturedValue(child, depth + 1);
118
+ }
119
+ if (entries.length > MAX_CAPTURED_CHILDREN) {
120
+ result.__truncated__ = `[${entries.length - MAX_CAPTURED_CHILDREN} fields omitted]`;
121
+ }
122
+ return result;
123
+ }
85
124
  /** Redact sensitive data from a single network request entry. */
86
125
  function redactNetworkRequest(req) {
87
126
  if (!req || typeof req !== 'object')
@@ -106,6 +145,12 @@ function redactNetworkRequest(req) {
106
145
  if (typeof redacted.body === 'string') {
107
146
  redacted.body = truncate(redacted.body, MAX_REQUEST_BODY_CHARS);
108
147
  }
148
+ if ('responseBody' in redacted) {
149
+ redacted.responseBody = sanitizeCapturedValue(redacted.responseBody);
150
+ }
151
+ if ('responsePreview' in redacted) {
152
+ redacted.responsePreview = sanitizeCapturedValue(redacted.responsePreview);
153
+ }
109
154
  return redacted;
110
155
  }
111
156
  // ── Timeout helper ───────────────────────────────────────────────────────────
@@ -163,23 +208,32 @@ function mapDistToSource(filePath) {
163
208
  export function isDiagnosticEnabled() {
164
209
  return process.env.OPENCLI_DIAGNOSTIC === '1';
165
210
  }
211
+ function normalizeInterceptedRequests(interceptedRequests) {
212
+ return interceptedRequests.slice(0, MAX_CAPTURED_PAYLOADS).map(responseBody => ({
213
+ source: 'interceptor',
214
+ responseBody: sanitizeCapturedValue(responseBody),
215
+ }));
216
+ }
166
217
  /** Safely collect page diagnostic state with redaction, size caps, and timeout. */
167
218
  async function collectPageState(page) {
168
219
  const collect = async () => {
169
220
  try {
170
- const [url, snapshot, networkRequests, consoleErrors] = await Promise.all([
221
+ const [url, snapshot, networkRequests, interceptedRequests, consoleErrors] = await Promise.all([
171
222
  page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
172
223
  page.snapshot().catch(() => '(snapshot unavailable)'),
173
224
  page.networkRequests().catch(() => []),
225
+ page.getInterceptedRequests().catch(() => []),
174
226
  page.consoleMessages('error').catch(() => []),
175
227
  ]);
176
228
  const rawUrl = url ?? 'unknown';
229
+ const capturedResponses = normalizeInterceptedRequests(interceptedRequests);
177
230
  return {
178
231
  url: redactUrl(rawUrl),
179
232
  snapshot: redactText(truncate(snapshot, MAX_SNAPSHOT_CHARS)),
180
233
  networkRequests: networkRequests
181
234
  .slice(0, MAX_NETWORK_REQUESTS)
182
235
  .map(redactNetworkRequest),
236
+ capturedPayloads: capturedResponses,
183
237
  consoleErrors: consoleErrors
184
238
  .slice(0, 50)
185
239
  .map(e => typeof e === 'string' ? redactText(e) : e),
@@ -235,7 +289,15 @@ export function emitDiagnostic(ctx) {
235
289
  let json = JSON.stringify(ctx);
236
290
  // Enforce total output budget — drop page state (largest section) first if over budget
237
291
  if (json.length > MAX_DIAGNOSTIC_BYTES && ctx.page) {
238
- const trimmed = { ...ctx, page: { ...ctx.page, snapshot: '[omitted: over size budget]', networkRequests: [] } };
292
+ const trimmed = {
293
+ ...ctx,
294
+ page: {
295
+ ...ctx.page,
296
+ snapshot: '[omitted: over size budget]',
297
+ networkRequests: [],
298
+ capturedPayloads: [],
299
+ },
300
+ };
239
301
  json = JSON.stringify(trimmed);
240
302
  }
241
303
  // If still over budget, drop page entirely
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { buildRepairContext, isDiagnosticEnabled, emitDiagnostic, truncate, redactUrl, redactText, resolveAdapterSourcePath, MAX_DIAGNOSTIC_BYTES, } from './diagnostic.js';
2
+ import { buildRepairContext, collectDiagnostic, isDiagnosticEnabled, emitDiagnostic, truncate, redactUrl, redactText, resolveAdapterSourcePath, MAX_DIAGNOSTIC_BYTES, } from './diagnostic.js';
3
3
  import { SelectorError, CommandExecutionError } from './errors.js';
4
4
  function makeCmd(overrides = {}) {
5
5
  return {
@@ -211,3 +211,93 @@ describe('emitDiagnostic', () => {
211
211
  expect(redactUrl('https://api.com/data?token=secret123')).toContain('[REDACTED]');
212
212
  });
213
213
  });
214
+ function makePage(overrides = {}) {
215
+ return {
216
+ goto: vi.fn(),
217
+ evaluate: vi.fn(),
218
+ getCookies: vi.fn(),
219
+ snapshot: vi.fn().mockResolvedValue('<div>...</div>'),
220
+ click: vi.fn(),
221
+ typeText: vi.fn(),
222
+ pressKey: vi.fn(),
223
+ scrollTo: vi.fn(),
224
+ getFormState: vi.fn(),
225
+ wait: vi.fn(),
226
+ tabs: vi.fn(),
227
+ selectTab: vi.fn(),
228
+ networkRequests: vi.fn().mockResolvedValue([]),
229
+ consoleMessages: vi.fn().mockResolvedValue([]),
230
+ scroll: vi.fn(),
231
+ autoScroll: vi.fn(),
232
+ installInterceptor: vi.fn(),
233
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
234
+ waitForCapture: vi.fn(),
235
+ screenshot: vi.fn(),
236
+ getCurrentUrl: vi.fn().mockResolvedValue('https://example.com/page'),
237
+ ...overrides,
238
+ };
239
+ }
240
+ describe('collectDiagnostic', () => {
241
+ it('keeps intercepted payloads in a dedicated capturedPayloads field', async () => {
242
+ const page = makePage({
243
+ networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
244
+ getInterceptedRequests: vi.fn().mockResolvedValue([{ items: [{ id: 1 }] }]),
245
+ });
246
+ const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
247
+ expect(ctx.page?.networkRequests).toEqual([
248
+ { url: '/api/data', status: 200 },
249
+ ]);
250
+ expect(ctx.page?.capturedPayloads).toEqual([
251
+ { source: 'interceptor', responseBody: { items: [{ id: 1 }] } },
252
+ ]);
253
+ });
254
+ it('preserves the previous network request output when interception is empty', async () => {
255
+ const page = makePage({
256
+ networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
257
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
258
+ });
259
+ const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
260
+ expect(ctx.page?.networkRequests).toEqual([{ url: '/api/data', status: 200 }]);
261
+ expect(ctx.page?.capturedPayloads).toEqual([]);
262
+ });
263
+ it('swallows intercepted request failures and still returns page state', async () => {
264
+ const page = makePage({
265
+ networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
266
+ getInterceptedRequests: vi.fn().mockRejectedValue(new Error('interceptor unavailable')),
267
+ });
268
+ const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
269
+ expect(ctx.page).toEqual({
270
+ url: 'https://example.com/page',
271
+ snapshot: '<div>...</div>',
272
+ networkRequests: [{ url: '/api/data', status: 200 }],
273
+ capturedPayloads: [],
274
+ consoleErrors: [],
275
+ });
276
+ });
277
+ it('redacts and truncates intercepted payloads recursively', async () => {
278
+ const page = makePage({
279
+ getInterceptedRequests: vi.fn().mockResolvedValue([{
280
+ token: 'token=abc123def456ghi789',
281
+ nested: {
282
+ cookie: 'cookie: session=super-secret-cookie-value',
283
+ body: 'x'.repeat(5000),
284
+ },
285
+ }]),
286
+ });
287
+ const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
288
+ const payload = ctx.page?.capturedPayloads?.[0];
289
+ const body = payload.responseBody.nested.body;
290
+ expect(payload).toEqual({
291
+ source: 'interceptor',
292
+ responseBody: {
293
+ token: 'token=[REDACTED]',
294
+ nested: {
295
+ cookie: 'cookie: [REDACTED]',
296
+ body,
297
+ },
298
+ },
299
+ });
300
+ expect(body).toContain('[truncated,');
301
+ expect(body.length).toBeLessThan(5000);
302
+ });
303
+ });
@@ -17,7 +17,9 @@ export type ConnectivityResult = {
17
17
  export type DoctorReport = {
18
18
  cliVersion?: string;
19
19
  daemonRunning: boolean;
20
+ daemonFlaky?: boolean;
20
21
  extensionConnected: boolean;
22
+ extensionFlaky?: boolean;
21
23
  extensionVersion?: string;
22
24
  connectivity?: ConnectivityResult;
23
25
  sessions?: Array<{