@jackwener/opencli 1.5.8 → 1.5.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 (194) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +17 -1
  3. package/README.zh-CN.md +17 -1
  4. package/dist/browser/base-page.d.ts +48 -0
  5. package/dist/browser/base-page.js +160 -0
  6. package/dist/browser/cdp.js +4 -106
  7. package/dist/browser/daemon-client.d.ts +1 -7
  8. package/dist/browser/daemon-client.js +2 -9
  9. package/dist/browser/discover.d.ts +1 -4
  10. package/dist/browser/discover.js +1 -4
  11. package/dist/browser/errors.d.ts +4 -0
  12. package/dist/browser/errors.js +20 -0
  13. package/dist/browser/index.d.ts +1 -1
  14. package/dist/browser/index.js +1 -1
  15. package/dist/browser/page.d.ts +6 -35
  16. package/dist/browser/page.js +10 -189
  17. package/dist/browser/tabs.js +5 -5
  18. package/dist/browser.test.js +15 -15
  19. package/dist/cli-manifest.json +294 -22
  20. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  21. package/dist/clis/amazon/bestsellers.js +130 -0
  22. package/dist/clis/amazon/bestsellers.test.js +20 -0
  23. package/dist/clis/amazon/discussion.d.ts +20 -0
  24. package/dist/clis/amazon/discussion.js +91 -0
  25. package/dist/clis/amazon/discussion.test.js +36 -0
  26. package/dist/clis/amazon/offer.d.ts +23 -0
  27. package/dist/clis/amazon/offer.js +140 -0
  28. package/dist/clis/amazon/offer.test.d.ts +1 -0
  29. package/dist/clis/amazon/offer.test.js +29 -0
  30. package/dist/clis/amazon/product.d.ts +18 -0
  31. package/dist/clis/amazon/product.js +92 -0
  32. package/dist/clis/amazon/product.test.d.ts +1 -0
  33. package/dist/clis/amazon/product.test.js +24 -0
  34. package/dist/clis/amazon/search.d.ts +18 -0
  35. package/dist/clis/amazon/search.js +87 -0
  36. package/dist/clis/amazon/search.test.d.ts +1 -0
  37. package/dist/clis/amazon/search.test.js +22 -0
  38. package/dist/clis/amazon/shared.d.ts +64 -0
  39. package/dist/clis/amazon/shared.js +255 -0
  40. package/dist/clis/amazon/shared.test.d.ts +1 -0
  41. package/dist/clis/amazon/shared.test.js +33 -0
  42. package/dist/clis/gemini/ask.d.ts +1 -0
  43. package/dist/clis/gemini/ask.js +40 -0
  44. package/dist/clis/gemini/image.d.ts +1 -0
  45. package/dist/clis/gemini/image.js +105 -0
  46. package/dist/clis/gemini/new.d.ts +1 -0
  47. package/dist/clis/gemini/new.js +20 -0
  48. package/dist/clis/gemini/utils.d.ts +34 -0
  49. package/dist/clis/gemini/utils.js +463 -0
  50. package/dist/clis/gemini/utils.test.d.ts +1 -0
  51. package/dist/clis/gemini/utils.test.js +31 -0
  52. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  53. package/dist/clis/notebooklm/compat.test.js +3 -3
  54. package/dist/clis/notebooklm/current.js +2 -3
  55. package/dist/clis/notebooklm/get.js +2 -3
  56. package/dist/clis/notebooklm/history.js +2 -3
  57. package/dist/clis/notebooklm/note-list.js +2 -3
  58. package/dist/clis/notebooklm/notes-get.js +2 -3
  59. package/dist/clis/notebooklm/open.d.ts +1 -0
  60. package/dist/clis/notebooklm/open.js +41 -0
  61. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  62. package/dist/clis/notebooklm/open.test.js +63 -0
  63. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  64. package/dist/clis/notebooklm/source-get.js +2 -3
  65. package/dist/clis/notebooklm/source-guide.js +2 -3
  66. package/dist/clis/notebooklm/source-list.js +2 -3
  67. package/dist/clis/notebooklm/status.js +1 -2
  68. package/dist/clis/notebooklm/summary.js +2 -3
  69. package/dist/clis/notebooklm/utils.d.ts +2 -1
  70. package/dist/clis/notebooklm/utils.js +20 -21
  71. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  72. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  73. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  74. package/dist/commanderAdapter.js +6 -3
  75. package/dist/commanderAdapter.test.js +33 -0
  76. package/dist/commands/daemon.js +1 -1
  77. package/dist/commands/daemon.test.js +1 -1
  78. package/dist/doctor.d.ts +1 -2
  79. package/dist/doctor.js +7 -8
  80. package/dist/explore.js +1 -1
  81. package/dist/output.js +28 -0
  82. package/dist/output.test.js +15 -0
  83. package/dist/pipeline/executor.js +2 -7
  84. package/dist/pipeline/steps/browser.js +1 -1
  85. package/dist/pipeline/template.js +25 -3
  86. package/dist/record.d.ts +50 -0
  87. package/dist/record.js +298 -57
  88. package/dist/record.test.d.ts +1 -0
  89. package/dist/record.test.js +293 -0
  90. package/dist/registry.d.ts +2 -0
  91. package/dist/registry.js +1 -0
  92. package/dist/registry.test.js +10 -0
  93. package/dist/runtime.js +3 -3
  94. package/dist/snapshotFormatter.d.ts +1 -1
  95. package/dist/snapshotFormatter.js +4 -4
  96. package/dist/snapshotFormatter.test.d.ts +1 -1
  97. package/dist/snapshotFormatter.test.js +2 -2
  98. package/dist/types.d.ts +3 -1
  99. package/dist/types.js +1 -1
  100. package/docs/.vitepress/config.mts +2 -0
  101. package/docs/adapters/browser/amazon.md +53 -0
  102. package/docs/adapters/browser/gemini.md +72 -0
  103. package/docs/adapters/browser/notebooklm.md +5 -5
  104. package/docs/adapters/index.md +3 -1
  105. package/extension/dist/background.js +5 -143
  106. package/extension/src/background.test.ts +7 -163
  107. package/extension/src/background.ts +7 -157
  108. package/extension/src/protocol.ts +1 -5
  109. package/package.json +1 -1
  110. package/skills/opencli-explorer/SKILL.md +847 -0
  111. package/skills/opencli-oneshot/SKILL.md +216 -0
  112. package/skills/opencli-usage/SKILL.md +71 -0
  113. package/skills/opencli-usage/browser.md +429 -0
  114. package/skills/opencli-usage/desktop.md +118 -0
  115. package/skills/opencli-usage/plugins.md +82 -0
  116. package/skills/opencli-usage/public-api.md +149 -0
  117. package/src/browser/base-page.ts +197 -0
  118. package/src/browser/cdp.ts +7 -131
  119. package/src/browser/daemon-client.ts +3 -14
  120. package/src/browser/discover.ts +1 -4
  121. package/src/browser/errors.ts +22 -0
  122. package/src/browser/index.ts +1 -1
  123. package/src/browser/page.ts +13 -212
  124. package/src/browser/tabs.ts +5 -5
  125. package/src/browser.test.ts +15 -15
  126. package/src/clis/amazon/bestsellers.test.ts +22 -0
  127. package/src/clis/amazon/bestsellers.ts +180 -0
  128. package/src/clis/amazon/discussion.test.ts +38 -0
  129. package/src/clis/amazon/discussion.ts +131 -0
  130. package/src/clis/amazon/offer.test.ts +35 -0
  131. package/src/clis/amazon/offer.ts +185 -0
  132. package/src/clis/amazon/product.test.ts +26 -0
  133. package/src/clis/amazon/product.ts +131 -0
  134. package/src/clis/amazon/search.test.ts +24 -0
  135. package/src/clis/amazon/search.ts +128 -0
  136. package/src/clis/amazon/shared.test.ts +37 -0
  137. package/src/clis/amazon/shared.ts +316 -0
  138. package/src/clis/gemini/ask.ts +46 -0
  139. package/src/clis/gemini/image.ts +115 -0
  140. package/src/clis/gemini/new.ts +22 -0
  141. package/src/clis/gemini/utils.test.ts +36 -0
  142. package/src/clis/gemini/utils.ts +523 -0
  143. package/src/clis/notebooklm/compat.test.ts +3 -3
  144. package/src/clis/notebooklm/current.ts +2 -3
  145. package/src/clis/notebooklm/get.ts +1 -3
  146. package/src/clis/notebooklm/history.ts +1 -3
  147. package/src/clis/notebooklm/note-list.ts +1 -3
  148. package/src/clis/notebooklm/notes-get.ts +1 -3
  149. package/src/clis/notebooklm/open.test.ts +78 -0
  150. package/src/clis/notebooklm/open.ts +61 -0
  151. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  152. package/src/clis/notebooklm/source-get.ts +1 -3
  153. package/src/clis/notebooklm/source-guide.ts +1 -3
  154. package/src/clis/notebooklm/source-list.ts +1 -3
  155. package/src/clis/notebooklm/status.ts +1 -2
  156. package/src/clis/notebooklm/summary.ts +1 -3
  157. package/src/clis/notebooklm/utils.ts +29 -20
  158. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  159. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  160. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  161. package/src/commanderAdapter.test.ts +47 -0
  162. package/src/commanderAdapter.ts +7 -3
  163. package/src/commands/daemon.test.ts +1 -1
  164. package/src/commands/daemon.ts +1 -1
  165. package/src/doctor.ts +7 -8
  166. package/src/explore.ts +1 -1
  167. package/src/output.test.ts +17 -0
  168. package/src/output.ts +27 -0
  169. package/src/pipeline/executor.ts +2 -7
  170. package/src/pipeline/steps/browser.ts +1 -1
  171. package/src/pipeline/template.ts +27 -4
  172. package/src/record.test.ts +362 -0
  173. package/src/record.ts +341 -62
  174. package/src/registry.test.ts +12 -0
  175. package/src/registry.ts +3 -0
  176. package/src/runtime.ts +3 -3
  177. package/src/snapshotFormatter.test.ts +2 -2
  178. package/src/snapshotFormatter.ts +4 -4
  179. package/src/types.ts +3 -1
  180. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  181. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  182. package/SKILL.md +0 -879
  183. package/dist/clis/notebooklm/bind-current.js +0 -29
  184. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  185. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  186. package/dist/clis/notebooklm/binding.test.js +0 -44
  187. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  188. package/src/clis/notebooklm/bind-current.ts +0 -36
  189. package/src/clis/notebooklm/binding.test.ts +0 -53
  190. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  191. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  192. /package/dist/clis/{notebooklm/bind-current.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  193. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/discussion.test.d.ts} +0 -0
  194. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -122,7 +122,7 @@ export async function daemonRestart(): Promise<void> {
122
122
  }
123
123
 
124
124
  // Import BrowserBridge to spawn a new daemon
125
- const { BrowserBridge } = await import('../browser/mcp.js');
125
+ const { BrowserBridge } = await import('../browser/bridge.js');
126
126
  const bridge = new BrowserBridge();
127
127
  try {
128
128
  console.log('Starting daemon...');
package/src/doctor.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * opencli doctor — diagnose browser connectivity.
3
3
  *
4
- * Simplified for the daemon-based architecture. No more token management,
5
- * MCP path discovery, or config file scanning.
4
+ * Simplified for the daemon-based architecture.
6
5
  */
7
6
 
8
7
  import chalk from 'chalk';
@@ -42,11 +41,11 @@ export type DoctorReport = {
42
41
  export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
43
42
  const start = Date.now();
44
43
  try {
45
- const mcp = new BrowserBridge();
46
- const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
44
+ const bridge = new BrowserBridge();
45
+ const page = await bridge.connect({ timeout: opts?.timeout ?? 8 });
47
46
  // Try a simple eval to verify end-to-end connectivity
48
47
  await page.evaluate('1 + 1');
49
- await mcp.close();
48
+ await bridge.close();
50
49
  return { ok: true, durationMs: Date.now() - start };
51
50
  } catch (err) {
52
51
  return { ok: false, error: getErrorMessage(err), durationMs: Date.now() - start };
@@ -58,9 +57,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
58
57
  let initialStatus = await checkDaemonStatus();
59
58
  if (!initialStatus.running) {
60
59
  try {
61
- const mcp = new BrowserBridge();
62
- await mcp.connect({ timeout: 5 });
63
- await mcp.close();
60
+ const bridge = new BrowserBridge();
61
+ await bridge.connect({ timeout: 5 });
62
+ await bridge.close();
64
63
  } catch {
65
64
  // Auto-start failed; we'll report it below.
66
65
  }
package/src/explore.ts CHANGED
@@ -135,7 +135,7 @@ export interface ExploreBundle {
135
135
  }
136
136
 
137
137
  /**
138
- * Parse raw network output from Playwright MCP.
138
+ * Parse raw network output from browser page.
139
139
  * Handles text format: [GET] url => [200]
140
140
  */
141
141
  function parseNetworkRequests(raw: unknown): NetworkEntry[] {
@@ -89,4 +89,21 @@ describe('render', () => {
89
89
  const calls = log.mock.calls.map(c => c[0]);
90
90
  expect(calls[1]).toBe('test,');
91
91
  });
92
+
93
+ it('renders single-field rows in plain mode as the bare value', () => {
94
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
95
+ render([{ response: 'Gemini says hi' }], { fmt: 'plain' });
96
+ expect(log).toHaveBeenCalledWith('Gemini says hi');
97
+ });
98
+
99
+ it('renders multi-field rows in plain mode as key-value lines', () => {
100
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
101
+ render([{ status: 'ok', file: '~/tmp/a.png', link: 'https://example.com' }], { fmt: 'plain' });
102
+ const calls = log.mock.calls.map(c => c[0]);
103
+ expect(calls).toEqual([
104
+ 'status: ok',
105
+ 'file: ~/tmp/a.png',
106
+ 'link: https://example.com',
107
+ ]);
108
+ });
92
109
  });
package/src/output.ts CHANGED
@@ -33,6 +33,7 @@ export function render(data: unknown, opts: RenderOptions = {}): void {
33
33
  }
34
34
  switch (fmt) {
35
35
  case 'json': renderJson(data); break;
36
+ case 'plain': renderPlain(data, opts); break;
36
37
  case 'md': case 'markdown': renderMarkdown(data, opts); break;
37
38
  case 'csv': renderCsv(data, opts); break;
38
39
  case 'yaml': case 'yml': renderYaml(data); break;
@@ -74,6 +75,32 @@ function renderTable(data: unknown, opts: RenderOptions): void {
74
75
  function renderJson(data: unknown): void {
75
76
  console.log(JSON.stringify(data, null, 2));
76
77
  }
78
+ function renderPlain(data: unknown, opts: RenderOptions): void {
79
+ const rows = normalizeRows(data);
80
+ if (!rows.length) return;
81
+
82
+ // Single-row single-field shortcuts for chat-style commands.
83
+ if (rows.length === 1) {
84
+ const row = rows[0];
85
+ const entries = Object.entries(row);
86
+ if (entries.length === 1) {
87
+ const [key, value] = entries[0];
88
+ if (key === 'response' || key === 'content' || key === 'text' || key === 'value') {
89
+ console.log(String(value ?? ''));
90
+ return;
91
+ }
92
+ }
93
+ }
94
+
95
+ rows.forEach((row, index) => {
96
+ const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== '');
97
+ entries.forEach(([key, value]) => {
98
+ console.log(`${key}: ${value}`);
99
+ });
100
+ if (index < rows.length - 1) console.log('');
101
+ });
102
+ }
103
+
77
104
 
78
105
  function renderMarkdown(data: unknown, opts: RenderOptions): void {
79
106
  const rows = normalizeRows(data);
@@ -8,6 +8,7 @@ import { getStep, type StepHandler } from './registry.js';
8
8
  import { log } from '../logger.js';
9
9
  import { ConfigError } from '../errors.js';
10
10
  import { BROWSER_ONLY_STEPS } from '../capabilityRouting.js';
11
+ import { isTransientBrowserError } from '../browser/errors.js';
11
12
 
12
13
  export interface PipelineContext {
13
14
  args?: Record<string, unknown>;
@@ -73,13 +74,7 @@ async function executeStepWithRetry(
73
74
  } catch (err) {
74
75
  if (attempt >= maxRetries) throw err;
75
76
  // Only retry on transient browser errors
76
- const msg = err instanceof Error ? err.message : '';
77
- const isTransient = msg.includes('Extension disconnected')
78
- || msg.includes('attach failed')
79
- || msg.includes('no longer exists')
80
- || msg.includes('CDP connection')
81
- || msg.includes('Daemon command failed');
82
- if (!isTransient) throw err;
77
+ if (!isTransientBrowserError(err)) throw err;
83
78
  // Brief delay before retry
84
79
  await new Promise(resolve => setTimeout(resolve, 1000));
85
80
  }
@@ -65,7 +65,7 @@ export async function stepSnapshot(page: IPage | null, params: unknown, _data: u
65
65
  export async function stepEvaluate(page: IPage | null, params: unknown, data: unknown, args: Record<string, unknown>): Promise<unknown> {
66
66
  const js = String(render(params, { args, data }));
67
67
  let result: unknown = await page!.evaluate(js);
68
- // MCP may return JSON as a string — auto-parse it
68
+ // Browser may return JSON as a string — auto-parse it
69
69
  if (typeof result === 'string') {
70
70
  const trimmed = result.trim();
71
71
  if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) {
@@ -172,6 +172,10 @@ export function resolvePath(pathStr: string, ctx: RenderContext): unknown {
172
172
  /**
173
173
  * Evaluate arbitrary JS expressions as a last-resort fallback.
174
174
  * Runs inside a `node:vm` sandbox with dynamic code generation disabled.
175
+ *
176
+ * Compiled functions are cached by expression string to avoid re-creating
177
+ * VM contexts on every invocation — critical for loops where the same
178
+ * expression is evaluated hundreds of times.
175
179
  */
176
180
  const FORBIDDEN_EXPR_PATTERNS = /\b(constructor|__proto__|prototype|globalThis|process|require|import|eval)\b/;
177
181
 
@@ -189,6 +193,25 @@ function sanitizeContext(obj: unknown): unknown {
189
193
  }
190
194
  }
191
195
 
196
+ /** LRU-bounded cache for compiled VM scripts — prevents unbounded memory growth. */
197
+ const MAX_VM_CACHE_SIZE = 256;
198
+ const _vmCache = new Map<string, vm.Script>();
199
+
200
+ function getOrCompileScript(expr: string): vm.Script {
201
+ let script = _vmCache.get(expr);
202
+ if (script) return script;
203
+
204
+ // Evict oldest entry when cache is full
205
+ if (_vmCache.size >= MAX_VM_CACHE_SIZE) {
206
+ const firstKey = _vmCache.keys().next().value;
207
+ if (firstKey !== undefined) _vmCache.delete(firstKey);
208
+ }
209
+
210
+ script = new vm.Script(`(${expr})`);
211
+ _vmCache.set(expr, script);
212
+ return script;
213
+ }
214
+
192
215
  function evalJsExpr(expr: string, ctx: RenderContext): unknown {
193
216
  // Guard against absurdly long expressions that could indicate injection.
194
217
  if (expr.length > 2000) return undefined;
@@ -202,8 +225,8 @@ function evalJsExpr(expr: string, ctx: RenderContext): unknown {
202
225
  const index = ctx.index ?? 0;
203
226
 
204
227
  try {
205
- return vm.runInNewContext(
206
- `(${expr})`,
228
+ const script = getOrCompileScript(expr);
229
+ const sandbox = vm.createContext(
207
230
  {
208
231
  args,
209
232
  item,
@@ -220,13 +243,13 @@ function evalJsExpr(expr: string, ctx: RenderContext): unknown {
220
243
  Date,
221
244
  },
222
245
  {
223
- timeout: 50,
224
- contextCodeGeneration: {
246
+ codeGeneration: {
225
247
  strings: false,
226
248
  wasm: false,
227
249
  },
228
250
  },
229
251
  );
252
+ return script.runInContext(sandbox, { timeout: 50 });
230
253
  } catch {
231
254
  return undefined;
232
255
  }
@@ -0,0 +1,362 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ analyzeRecordedRequests,
4
+ buildWriteRecordedYaml,
5
+ createRecordedEntry,
6
+ generateFullCaptureInterceptorJs,
7
+ generateRecordedCandidates,
8
+ } from './record.js';
9
+ import { render } from './pipeline/template.js';
10
+
11
+ describe('record request-body capture', () => {
12
+ it('captures a JSON fetch request body alongside the JSON response body', () => {
13
+ const entry = createRecordedEntry({
14
+ url: 'https://api.example.com/tasks',
15
+ method: 'POST',
16
+ requestContentType: 'application/json',
17
+ requestBodyText: '{"title":"Ship #601","priority":"high"}',
18
+ responseBody: { id: 'task_123', ok: true },
19
+ });
20
+
21
+ expect(entry).toMatchObject({
22
+ method: 'POST',
23
+ requestContentType: 'application/json',
24
+ requestBody: { title: 'Ship #601', priority: 'high' },
25
+ responseBody: { id: 'task_123', ok: true },
26
+ });
27
+ });
28
+
29
+ it('captures a JSON request body from fetch(Request)', async () => {
30
+ class MockXMLHttpRequest {
31
+ open(): void {}
32
+ send(): void {}
33
+ setRequestHeader(): void {}
34
+ addEventListener(): void {}
35
+ getResponseHeader(): string | null { return null; }
36
+ responseText = '';
37
+ }
38
+
39
+ const mockFetch = vi.fn(async () => new Response(
40
+ JSON.stringify({ id: 'task_123', ok: true }),
41
+ { headers: { 'content-type': 'application/json' } },
42
+ ));
43
+
44
+ vi.stubGlobal('fetch', mockFetch);
45
+ vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest);
46
+ vi.stubGlobal('window', globalThis);
47
+
48
+ // eslint-disable-next-line no-eval
49
+ eval(generateFullCaptureInterceptorJs());
50
+
51
+ const request = new Request('https://api.example.com/tasks', {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/json' },
54
+ body: JSON.stringify({ title: 'Ship #601' }),
55
+ });
56
+
57
+ await globalThis.fetch(request);
58
+
59
+ const recorded = (globalThis as typeof globalThis & { __opencli_record?: Array<{ requestBody: unknown }> }).__opencli_record;
60
+ expect(recorded).toHaveLength(1);
61
+ expect(recorded?.[0]?.requestBody).toEqual({ title: 'Ship #601' });
62
+ });
63
+
64
+ it('captures a JSON request body from XHR send()', async () => {
65
+ class MockXMLHttpRequest {
66
+ __listeners: Record<string, Array<() => void>> = {};
67
+ __rec_url?: string;
68
+ __rec_method?: string;
69
+ __rec_request_content_type?: string | null;
70
+ responseText = JSON.stringify({ id: 'task_456', ok: true });
71
+
72
+ open(method: string, url: string): void {
73
+ this.__rec_method = method;
74
+ this.__rec_url = url;
75
+ }
76
+
77
+ send(): void {
78
+ for (const listener of this.__listeners.load ?? []) listener.call(this);
79
+ }
80
+
81
+ setRequestHeader(name: string, value: string): void {
82
+ if (name.toLowerCase() === 'content-type') this.__rec_request_content_type = value;
83
+ }
84
+
85
+ addEventListener(event: string, listener: () => void): void {
86
+ this.__listeners[event] ??= [];
87
+ this.__listeners[event].push(listener);
88
+ }
89
+
90
+ getResponseHeader(name: string): string | null {
91
+ return name.toLowerCase() === 'content-type' ? 'application/json' : null;
92
+ }
93
+ }
94
+
95
+ const mockFetch = vi.fn(async () => new Response(
96
+ JSON.stringify({ ok: true }),
97
+ { headers: { 'content-type': 'application/json' } },
98
+ ));
99
+
100
+ vi.stubGlobal('fetch', mockFetch);
101
+ vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest);
102
+ vi.stubGlobal('window', globalThis);
103
+
104
+ // eslint-disable-next-line no-eval
105
+ eval(generateFullCaptureInterceptorJs());
106
+
107
+ const xhr = new XMLHttpRequest();
108
+ xhr.open('PATCH', 'https://api.example.com/tasks/submit');
109
+ xhr.setRequestHeader('content-type', 'application/json;charset=utf-8');
110
+ xhr.send('{"done":true}');
111
+
112
+ const recorded = (globalThis as typeof globalThis & { __opencli_record?: Array<{ requestBody: unknown }> }).__opencli_record;
113
+ expect(recorded).toHaveLength(1);
114
+ expect(recorded?.[0]?.requestBody).toEqual({ done: true });
115
+ });
116
+
117
+ it('does not interrupt fetch when reading a Request body fails', async () => {
118
+ class MockXMLHttpRequest {
119
+ open(): void {}
120
+ send(): void {}
121
+ setRequestHeader(): void {}
122
+ addEventListener(): void {}
123
+ getResponseHeader(): string | null { return null; }
124
+ responseText = '';
125
+ }
126
+
127
+ class BrokenRequest extends Request {
128
+ override clone(): Request {
129
+ throw new Error('clone failed');
130
+ }
131
+ }
132
+
133
+ const mockFetch = vi.fn(async () => new Response(
134
+ JSON.stringify({ id: 'task_123', ok: true }),
135
+ { headers: { 'content-type': 'application/json' } },
136
+ ));
137
+
138
+ vi.stubGlobal('fetch', mockFetch);
139
+ vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest);
140
+ vi.stubGlobal('window', globalThis);
141
+
142
+ // eslint-disable-next-line no-eval
143
+ eval(generateFullCaptureInterceptorJs());
144
+
145
+ const request = new BrokenRequest('https://api.example.com/tasks', {
146
+ method: 'POST',
147
+ headers: { 'content-type': 'application/json' },
148
+ body: JSON.stringify({ title: 'Ship #601' }),
149
+ });
150
+
151
+ await expect(globalThis.fetch(request)).resolves.toBeInstanceOf(Response);
152
+ expect(mockFetch).toHaveBeenCalledTimes(1);
153
+ });
154
+ });
155
+
156
+ describe('record write candidates', () => {
157
+ it('keeps a POST request with JSON request body and object response as a write candidate', () => {
158
+ const result = analyzeRecordedRequests([
159
+ createRecordedEntry({
160
+ url: 'https://api.example.com/tasks/create',
161
+ method: 'POST',
162
+ requestContentType: 'application/json',
163
+ requestBodyText: '{"title":"Ship #601"}',
164
+ responseBody: { id: 'task_123', ok: true },
165
+ }),
166
+ ]);
167
+
168
+ expect(result.candidates).toHaveLength(1);
169
+ expect(result.candidates[0]).toMatchObject({
170
+ kind: 'write',
171
+ req: { method: 'POST' },
172
+ });
173
+ });
174
+
175
+ it('accepts vendor JSON content types for write candidates', () => {
176
+ const result = analyzeRecordedRequests([
177
+ createRecordedEntry({
178
+ url: 'https://api.example.com/tasks',
179
+ method: 'POST',
180
+ requestContentType: 'application/vnd.api+json',
181
+ requestBodyText: '{"title":"Ship #601"}',
182
+ responseBody: { id: 'task_123', ok: true },
183
+ }),
184
+ ]);
185
+
186
+ expect(result.candidates).toHaveLength(1);
187
+ expect(result.candidates[0]).toMatchObject({
188
+ kind: 'write',
189
+ req: { method: 'POST' },
190
+ });
191
+ });
192
+
193
+ it('rejects a POST request that has no usable JSON request body', () => {
194
+ const result = analyzeRecordedRequests([
195
+ createRecordedEntry({
196
+ url: 'https://api.example.com/tasks/create',
197
+ method: 'POST',
198
+ requestContentType: 'application/json',
199
+ requestBodyText: '',
200
+ responseBody: { id: 'task_123', ok: true },
201
+ }),
202
+ ]);
203
+
204
+ expect(result.candidates).toEqual([]);
205
+ });
206
+
207
+ it('rejects array request and response bodies for first-version write candidates', () => {
208
+ const result = analyzeRecordedRequests([
209
+ createRecordedEntry({
210
+ url: 'https://api.example.com/tasks/batch',
211
+ method: 'POST',
212
+ requestContentType: 'application/json',
213
+ requestBodyText: '[{"title":"Ship #601"}]',
214
+ responseBody: [{ id: 'task_123' }],
215
+ }),
216
+ ]);
217
+
218
+ expect(result.candidates).toEqual([]);
219
+ });
220
+
221
+ it('generates a write YAML candidate from a replayable JSON write request', () => {
222
+ const candidates = generateRecordedCandidates('demo', 'https://demo.example.com/app', [
223
+ createRecordedEntry({
224
+ url: 'https://api.example.com/tasks/create',
225
+ method: 'POST',
226
+ requestContentType: 'application/json',
227
+ requestBodyText: '{"title":"Ship #601"}',
228
+ responseBody: { id: 'task_123', ok: true },
229
+ }),
230
+ ]);
231
+
232
+ expect(candidates).toHaveLength(1);
233
+ expect(candidates[0]).toMatchObject({
234
+ kind: 'write',
235
+ name: 'create',
236
+ strategy: 'cookie',
237
+ });
238
+ expect(JSON.stringify(candidates[0].yaml)).toContain('Ship #601');
239
+ });
240
+
241
+ it('builds a write template that replays the recorded JSON body with application/json', () => {
242
+ const candidate = buildWriteRecordedYaml(
243
+ 'demo',
244
+ 'https://demo.example.com/app',
245
+ createRecordedEntry({
246
+ url: 'https://api.example.com/tasks/create',
247
+ method: 'POST',
248
+ requestContentType: 'application/json',
249
+ requestBodyText: '{"title":"Ship #601"}',
250
+ responseBody: { id: 'task_123', ok: true },
251
+ }),
252
+ 'create',
253
+ );
254
+
255
+ expect(candidate.name).toBe('create');
256
+ expect(JSON.stringify(candidate.yaml)).toContain('method: \\"POST\\"');
257
+ expect(JSON.stringify(candidate.yaml)).toContain('content-type');
258
+ expect(JSON.stringify(candidate.yaml)).toContain('Ship #601');
259
+ });
260
+ });
261
+
262
+ describe('record read candidates', () => {
263
+ it('keeps existing read candidates for array responses', () => {
264
+ const result = analyzeRecordedRequests([
265
+ {
266
+ url: 'https://api.example.com/feed',
267
+ method: 'GET',
268
+ status: null,
269
+ requestContentType: null,
270
+ responseContentType: 'application/json',
271
+ requestBody: null,
272
+ responseBody: { items: [{ title: 'A' }, { title: 'B' }] },
273
+ contentType: 'application/json',
274
+ body: { items: [{ title: 'A' }, { title: 'B' }] },
275
+ capturedAt: 1,
276
+ },
277
+ ]);
278
+
279
+ expect(result.candidates).toHaveLength(1);
280
+ expect(result.candidates[0]).toMatchObject({ kind: 'read' });
281
+ });
282
+
283
+ it('keeps read YAML generation on the baseline fetch path', () => {
284
+ const candidates = generateRecordedCandidates('demo', 'https://demo.example.com/app', [
285
+ createRecordedEntry({
286
+ url: 'https://api.example.com/search?q=test',
287
+ method: 'GET',
288
+ responseBody: { items: [{ title: 'A' }, { title: 'B' }] },
289
+ }),
290
+ ]);
291
+
292
+ const yaml = candidates[0].yaml as { pipeline: Array<{ evaluate?: string }> };
293
+ expect(yaml.pipeline[1]?.evaluate).toContain(`fetch("https://api.example.com/search?q=`);
294
+ expect(yaml.pipeline[1]?.evaluate).toContain(`{ credentials: 'include' }`);
295
+ expect(yaml.pipeline[1]?.evaluate).not.toContain('method: "POST"');
296
+ expect(yaml.pipeline[1]?.evaluate).not.toContain('body: JSON.stringify');
297
+ });
298
+
299
+ it('renders search and page args into the read YAML fetch URL', () => {
300
+ const candidates = generateRecordedCandidates('demo', 'https://demo.example.com/app', [
301
+ createRecordedEntry({
302
+ url: 'https://api.example.com/search?q=test&page=2',
303
+ method: 'GET',
304
+ responseBody: { items: [{ title: 'A' }, { title: 'B' }] },
305
+ }),
306
+ ]);
307
+
308
+ const yaml = candidates[0].yaml as { pipeline: Array<{ evaluate?: string }> };
309
+ const renderedEvaluate = render(yaml.pipeline[1]?.evaluate, {
310
+ args: { keyword: 'desk', page: 3 },
311
+ });
312
+
313
+ expect(renderedEvaluate).toContain('https://api.example.com/search?q=desk&page=3');
314
+ });
315
+
316
+ it('keeps GET and POST candidates separate when they share the same URL pattern', () => {
317
+ const candidates = generateRecordedCandidates('demo', 'https://demo.example.com/app', [
318
+ createRecordedEntry({
319
+ url: 'https://api.example.com/tasks',
320
+ method: 'GET',
321
+ responseBody: { items: [{ title: 'A' }, { title: 'B' }] },
322
+ }),
323
+ createRecordedEntry({
324
+ url: 'https://api.example.com/tasks',
325
+ method: 'POST',
326
+ requestContentType: 'application/json',
327
+ requestBodyText: '{"title":"Ship #601"}',
328
+ responseBody: { id: 'task_123', ok: true },
329
+ }),
330
+ ]);
331
+
332
+ expect(candidates).toHaveLength(2);
333
+ expect(candidates.some((candidate) => candidate.kind === 'read')).toBe(true);
334
+ expect(candidates.some((candidate) => candidate.kind === 'write')).toBe(true);
335
+ });
336
+ });
337
+
338
+ describe('record noise filtering', () => {
339
+ it('filters analytics POST noise from write candidates', () => {
340
+ const result = analyzeRecordedRequests([
341
+ createRecordedEntry({
342
+ url: 'https://api.example.com/analytics/event',
343
+ method: 'POST',
344
+ requestContentType: 'application/json',
345
+ requestBodyText: '{"event":"click"}',
346
+ responseBody: { ok: true, accepted: 1 },
347
+ }),
348
+ ]);
349
+
350
+ expect(result.candidates).toEqual([]);
351
+ });
352
+ });
353
+
354
+ afterEach(() => {
355
+ vi.unstubAllGlobals();
356
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_record?: unknown }, '__opencli_record');
357
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_record_patched?: unknown }, '__opencli_record_patched');
358
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_orig_fetch?: unknown }, '__opencli_orig_fetch');
359
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_orig_xhr_open?: unknown }, '__opencli_orig_xhr_open');
360
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_orig_xhr_send?: unknown }, '__opencli_orig_xhr_send');
361
+ Reflect.deleteProperty(globalThis as typeof globalThis & { __opencli_orig_xhr_set_request_header?: unknown }, '__opencli_orig_xhr_set_request_header');
362
+ });