@jackwener/opencli 1.5.8 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +35 -1
- package/README.zh-CN.md +17 -1
- package/SKILL.md +31 -851
- package/autoresearch/baseline-browse.txt +1 -0
- package/autoresearch/baseline-skill.txt +1 -0
- package/autoresearch/browse-tasks.json +688 -0
- package/autoresearch/eval-browse.ts +185 -0
- package/autoresearch/eval-skill.ts +248 -0
- package/autoresearch/run-browse.sh +9 -0
- package/autoresearch/run-skill.sh +9 -0
- package/dist/browser/base-page.d.ts +48 -0
- package/dist/browser/base-page.js +160 -0
- package/dist/browser/cdp.js +4 -106
- package/dist/browser/daemon-client.d.ts +20 -7
- package/dist/browser/daemon-client.js +39 -39
- package/dist/browser/daemon-client.test.js +77 -0
- package/dist/browser/discover.d.ts +1 -4
- package/dist/browser/discover.js +9 -23
- package/dist/browser/errors.d.ts +4 -0
- package/dist/browser/errors.js +20 -0
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/page.d.ts +10 -35
- package/dist/browser/page.js +55 -187
- package/dist/browser/tabs.js +5 -5
- package/dist/browser.test.js +15 -15
- package/dist/cli-manifest.json +294 -22
- package/dist/cli.js +392 -0
- package/dist/clis/amazon/bestsellers.d.ts +21 -0
- package/dist/clis/amazon/bestsellers.js +130 -0
- package/dist/clis/amazon/bestsellers.test.js +20 -0
- package/dist/clis/amazon/discussion.d.ts +20 -0
- package/dist/clis/amazon/discussion.js +91 -0
- package/dist/clis/amazon/discussion.test.d.ts +1 -0
- package/dist/clis/amazon/discussion.test.js +36 -0
- package/dist/clis/amazon/offer.d.ts +23 -0
- package/dist/clis/amazon/offer.js +140 -0
- package/dist/clis/amazon/offer.test.d.ts +1 -0
- package/dist/clis/amazon/offer.test.js +29 -0
- package/dist/clis/amazon/product.d.ts +18 -0
- package/dist/clis/amazon/product.js +92 -0
- package/dist/clis/amazon/product.test.d.ts +1 -0
- package/dist/clis/amazon/product.test.js +24 -0
- package/dist/clis/amazon/search.d.ts +18 -0
- package/dist/clis/amazon/search.js +87 -0
- package/dist/clis/amazon/search.test.d.ts +1 -0
- package/dist/clis/amazon/search.test.js +22 -0
- package/dist/clis/amazon/shared.d.ts +64 -0
- package/dist/clis/amazon/shared.js +255 -0
- package/dist/clis/amazon/shared.test.d.ts +1 -0
- package/dist/clis/amazon/shared.test.js +33 -0
- package/dist/clis/gemini/ask.d.ts +1 -0
- package/dist/clis/gemini/ask.js +40 -0
- package/dist/clis/gemini/image.d.ts +1 -0
- package/dist/clis/gemini/image.js +105 -0
- package/dist/clis/gemini/new.d.ts +1 -0
- package/dist/clis/gemini/new.js +20 -0
- package/dist/clis/gemini/utils.d.ts +34 -0
- package/dist/clis/gemini/utils.js +463 -0
- package/dist/clis/gemini/utils.test.d.ts +1 -0
- package/dist/clis/gemini/utils.test.js +31 -0
- package/dist/clis/notebooklm/compat.test.d.ts +1 -1
- package/dist/clis/notebooklm/compat.test.js +3 -3
- package/dist/clis/notebooklm/current.js +2 -3
- package/dist/clis/notebooklm/get.js +2 -3
- package/dist/clis/notebooklm/history.js +2 -3
- package/dist/clis/notebooklm/note-list.js +2 -3
- package/dist/clis/notebooklm/notes-get.js +2 -3
- package/dist/clis/notebooklm/open.d.ts +1 -0
- package/dist/clis/notebooklm/open.js +41 -0
- package/dist/clis/notebooklm/open.test.d.ts +1 -0
- package/dist/clis/notebooklm/open.test.js +63 -0
- package/dist/clis/notebooklm/source-fulltext.js +2 -3
- package/dist/clis/notebooklm/source-get.js +2 -3
- package/dist/clis/notebooklm/source-guide.js +2 -3
- package/dist/clis/notebooklm/source-list.js +2 -3
- package/dist/clis/notebooklm/status.js +1 -2
- package/dist/clis/notebooklm/summary.js +2 -3
- package/dist/clis/notebooklm/utils.d.ts +2 -1
- package/dist/clis/notebooklm/utils.js +20 -21
- package/dist/clis/twitter/article.js +28 -1
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
- package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
- package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
- package/dist/clis/xiaohongshu/note.js +11 -0
- package/dist/clis/xiaohongshu/note.test.js +49 -0
- package/dist/commanderAdapter.js +7 -4
- package/dist/commanderAdapter.test.js +76 -0
- package/dist/commands/daemon.js +8 -47
- package/dist/commands/daemon.test.js +45 -70
- package/dist/discovery.js +27 -0
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +7 -8
- package/dist/explore.js +1 -1
- package/dist/output.js +28 -0
- package/dist/output.test.js +15 -0
- package/dist/pipeline/executor.js +2 -7
- package/dist/pipeline/steps/browser.js +1 -1
- package/dist/pipeline/template.js +25 -3
- package/dist/record.d.ts +50 -0
- package/dist/record.js +298 -57
- package/dist/record.test.d.ts +1 -0
- package/dist/record.test.js +293 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +1 -0
- package/dist/registry.test.js +10 -0
- package/dist/runtime.js +3 -3
- package/dist/snapshotFormatter.d.ts +1 -1
- package/dist/snapshotFormatter.js +4 -4
- package/dist/snapshotFormatter.test.d.ts +1 -1
- package/dist/snapshotFormatter.test.js +2 -2
- package/dist/types.d.ts +11 -1
- package/dist/types.js +1 -1
- package/docs/.vitepress/config.mts +2 -0
- package/docs/adapters/browser/amazon.md +53 -0
- package/docs/adapters/browser/gemini.md +72 -0
- package/docs/adapters/browser/notebooklm.md +5 -5
- package/docs/adapters/index.md +3 -1
- package/docs/guide/getting-started.md +21 -0
- package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
- package/docs/zh/guide/getting-started.md +21 -0
- package/extension/package-lock.json +2 -2
- package/extension/src/background.test.ts +7 -163
- package/extension/src/background.ts +58 -161
- package/extension/src/cdp.ts +77 -124
- package/extension/src/protocol.ts +5 -5
- package/package.json +1 -1
- package/skills/opencli-explorer/SKILL.md +853 -0
- package/skills/opencli-oneshot/SKILL.md +222 -0
- package/skills/opencli-operate/SKILL.md +213 -0
- package/skills/opencli-usage/SKILL.md +152 -0
- package/skills/opencli-usage/browser.md +429 -0
- package/skills/opencli-usage/desktop.md +118 -0
- package/skills/opencli-usage/plugins.md +82 -0
- package/skills/opencli-usage/public-api.md +149 -0
- package/src/browser/base-page.ts +197 -0
- package/src/browser/cdp.ts +7 -131
- package/src/browser/daemon-client.test.ts +103 -0
- package/src/browser/daemon-client.ts +55 -43
- package/src/browser/discover.ts +9 -21
- package/src/browser/errors.ts +22 -0
- package/src/browser/index.ts +1 -1
- package/src/browser/page.ts +57 -209
- package/src/browser/tabs.ts +5 -5
- package/src/browser.test.ts +15 -15
- package/src/cli.ts +392 -0
- package/src/clis/amazon/bestsellers.test.ts +22 -0
- package/src/clis/amazon/bestsellers.ts +180 -0
- package/src/clis/amazon/discussion.test.ts +38 -0
- package/src/clis/amazon/discussion.ts +131 -0
- package/src/clis/amazon/offer.test.ts +35 -0
- package/src/clis/amazon/offer.ts +185 -0
- package/src/clis/amazon/product.test.ts +26 -0
- package/src/clis/amazon/product.ts +131 -0
- package/src/clis/amazon/search.test.ts +24 -0
- package/src/clis/amazon/search.ts +128 -0
- package/src/clis/amazon/shared.test.ts +37 -0
- package/src/clis/amazon/shared.ts +316 -0
- package/src/clis/gemini/ask.ts +46 -0
- package/src/clis/gemini/image.ts +115 -0
- package/src/clis/gemini/new.ts +22 -0
- package/src/clis/gemini/utils.test.ts +36 -0
- package/src/clis/gemini/utils.ts +523 -0
- package/src/clis/notebooklm/compat.test.ts +3 -3
- package/src/clis/notebooklm/current.ts +2 -3
- package/src/clis/notebooklm/get.ts +1 -3
- package/src/clis/notebooklm/history.ts +1 -3
- package/src/clis/notebooklm/note-list.ts +1 -3
- package/src/clis/notebooklm/notes-get.ts +1 -3
- package/src/clis/notebooklm/open.test.ts +78 -0
- package/src/clis/notebooklm/open.ts +61 -0
- package/src/clis/notebooklm/source-fulltext.ts +1 -3
- package/src/clis/notebooklm/source-get.ts +1 -3
- package/src/clis/notebooklm/source-guide.ts +1 -3
- package/src/clis/notebooklm/source-list.ts +1 -3
- package/src/clis/notebooklm/status.ts +1 -2
- package/src/clis/notebooklm/summary.ts +1 -3
- package/src/clis/notebooklm/utils.ts +29 -20
- package/src/clis/twitter/article.ts +31 -1
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
- package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
- package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
- package/src/clis/xiaohongshu/note.test.ts +51 -0
- package/src/clis/xiaohongshu/note.ts +18 -0
- package/src/commanderAdapter.test.ts +109 -0
- package/src/commanderAdapter.ts +8 -4
- package/src/commands/daemon.test.ts +50 -84
- package/src/commands/daemon.ts +8 -56
- package/src/discovery.ts +22 -0
- package/src/doctor.ts +8 -9
- package/src/explore.ts +1 -1
- package/src/output.test.ts +17 -0
- package/src/output.ts +27 -0
- package/src/pipeline/executor.ts +2 -7
- package/src/pipeline/steps/browser.ts +1 -1
- package/src/pipeline/template.ts +27 -4
- package/src/record.test.ts +362 -0
- package/src/record.ts +341 -62
- package/src/registry.test.ts +12 -0
- package/src/registry.ts +3 -0
- package/src/runtime.ts +3 -3
- package/src/snapshotFormatter.test.ts +2 -2
- package/src/snapshotFormatter.ts +4 -4
- package/src/types.ts +11 -1
- package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
- package/.agents/workflows/cross-project-adapter-migration.md +0 -54
- package/dist/clis/notebooklm/bind-current.js +0 -29
- package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
- package/dist/clis/notebooklm/bind-current.test.js +0 -35
- package/dist/clis/notebooklm/binding.test.js +0 -44
- package/extension/dist/background.js +0 -819
- package/src/clis/notebooklm/bind-current.test.ts +0 -43
- package/src/clis/notebooklm/bind-current.ts +0 -36
- package/src/clis/notebooklm/binding.test.ts +0 -53
- /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
- /package/dist/browser/{mcp.js → bridge.js} +0 -0
- /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
- /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
- /package/src/browser/{mcp.ts → bridge.ts} +0 -0
package/src/output.test.ts
CHANGED
|
@@ -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);
|
package/src/pipeline/executor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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('}'))) {
|
package/src/pipeline/template.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
+
});
|