mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
f594e500a8
* feat: AutoResearch framework + V2EX test suite (40 tasks) AutoResearch framework (Karpathy-style autonomous iteration): - engine.ts: 8-phase loop (review → modify → commit → verify → guard → decide → log) - config.ts: typed config + CLI parser + metric extraction - logger.ts: TSV append-only results log - commands/run.ts: main loop spawning Claude Code per iteration - commands/plan.ts: interactive config wizard - commands/fix.ts: auto-detect broken state, iteratively fix - commands/debug.ts: hypothesis-driven debugging for failing tasks V2EX test suite (5 layers, 40 tasks): - L1 Atomic (10): open, state, click, scroll, eval, back, wait - L2 Single Page (10): hot topics, node list, topic meta, pagination - L3 Multi-Step (10): click-read, navigate-node, tab-then-topic, pagination - L4 Write Ops (5): reply typing, favorite detection, form detection - L5 Complex Chain (5): cross-page collect, multi-node compare, full workflow Presets: operate-reliability, skill-quality, v2ex-reliability * test: V2EX test suite 60/60 — fix selectors, add harder tasks - Fix v2ex-collect-hot-authors selector (pathname-based member link detection) - Fix v2ex-wait-text judge (accept "appeared") - Fix trailing commas in eval step strings - Add 20 harder tasks: state+click interaction + long chain workflows - Baseline: 60/60 across all layers * feat: Zhihu test suite — 60 tasks across 8 layers, 60/60 passing Knowledge-intensive Chinese Q&A site (React SPA, lazy loading, complex DOM): - L1 Atomic (10): open, state, title, url, scroll, tab, back, wait, keys, screenshot - L2 Feed (8): feed titles, hot list, metrics, tabs, authors, content types, avatar, search - L3 Question (8): title, meta, answer, votes, buttons, descriptions, answer count - L4 Navigation (8): hot→question, feed→question, author profile, search, topic, user, back - L5 Write (6): upvote/follow/comment/bookmark/write-answer/share button detection - L6 Chain (8): read-answer-author, author-profile, multi-hot, search-then-read, scroll-answers - L7 Search (6): basic, people, topic, click-result, filter, back - L8 Complex (6): full workflow, deep author chain, cross-question, search-read, 3-page, scroll-deep Key fixes during development: - Zhihu search page needs 5s+ wait (SPA lazy loading) - Back navigation goes to about:blank (daemon init page), fixed with direct navigate - User profile answers page needs 4s wait for content - Broader selectors needed (h2 a instead of specific class names) * feat: combined eval-all runner + combined-reliability preset * experiment(operate): fix extract-npm-description + nav-click-link-example Round 1: Fix 2 remaining browse-tasks failures: - extract-npm-description: use generic <p> selector instead of class-based - nav-click-link-example: include URL in output (title is 'Example Domains', not 'IANA') * experiment(operate): fix bench-imdb-matrix — use broader selectors for year/rating Round 2: IMDB page selectors were too specific (data-testid changed). Use generic h1 for title, link text match for year, broader class match for rating. * experiment(operate): add edge cases + fix SPA navigation timing Round 3: Add 10 edge case tasks (5 V2EX + 5 Zhihu): - rapid-navigate: 3 consecutive opens - eval-after-click: verify URL changes after SPA click - scroll-and-extract: extract after deep scroll - structured extraction: multi-field JSON from dynamic content - lazy-load answers: scroll triggers more content Key finding: Zhihu SPA click() doesn't update location.pathname immediately. Use window.location.href = a.href for reliable navigation. V2EX: 65/65, Zhihu: 65/65, Browse: 59/59 = 189/189 * experiment(operate): add agent-style tasks using state+click+type (no eval for interaction) Round 4-5: Add 5 tasks that test the actual agent workflow: - agent-click-first-topic: find topic index via data-opencli-ref - agent-type-search: type into search using state index - agent-click-navigate-back: click by ref, verify navigation - agent-state-has-interactive: verify state output format - agent-state-after-scroll: verify scroll position in state V2EX: 70/70 tasks * fix: review fixes — extractVerdict, stderr, dead code - eval-skill.ts: remove dead TASKS_FILE variable (skill-tasks.yaml never existed) - eval-skill.ts: rewrite extractVerdict to use brace-counting JSON.parse instead of regex (handles escaped quotes in explanation) - eval-browse.ts: include stderr in runCommand error output for debuggability
255 lines
14 KiB
TypeScript
255 lines
14 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
|
|
*
|
|
* Spawns Claude Code with the opencli-operate skill. Claude Code
|
|
* completes the task using browse commands AND judges its own result.
|
|
*
|
|
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
|
|
*
|
|
* Usage:
|
|
* npx tsx autoresearch/eval-skill.ts # Run all
|
|
* npx tsx autoresearch/eval-skill.ts --task hn-top5 # Run single
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const RESULTS_DIR = join(__dirname, 'results');
|
|
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────
|
|
|
|
interface SkillTask {
|
|
name: string;
|
|
task: string;
|
|
url?: string;
|
|
judge_context: string[];
|
|
max_steps?: number;
|
|
}
|
|
|
|
interface TaskResult {
|
|
name: string;
|
|
passed: boolean;
|
|
duration: number;
|
|
cost: number;
|
|
explanation: string;
|
|
}
|
|
|
|
// ── Task Definitions (inline, to avoid YAML dependency) ────────────
|
|
|
|
const TASKS: SkillTask[] = [
|
|
// Extract
|
|
{ name: "extract-title-example", task: "Extract the main heading text from this page", url: "https://example.com", judge_context: ["Output must contain 'Example Domain'"] },
|
|
{ name: "extract-paragraph-wiki", task: "Extract the first paragraph of the JavaScript article", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must mention 'programming language'", "Output must contain actual paragraph text, not just the title"] },
|
|
{ name: "extract-github-stars", task: "Find the number of stars on this repository", url: "https://github.com/browser-use/browser-use", judge_context: ["Output must contain a number (the star count)"] },
|
|
{ name: "extract-npm-downloads", task: "Find the weekly download count for this package", url: "https://www.npmjs.com/package/zod", judge_context: ["Output must contain a number (weekly downloads)"] },
|
|
|
|
// List extraction
|
|
{ name: "list-hn-top5", task: "Extract the top 5 stories with their titles", url: "https://news.ycombinator.com", judge_context: ["Output must contain 5 story titles", "Each title must be an actual HN story, not made up"] },
|
|
{ name: "list-books-5", task: "Extract the first 5 books with their title and price", url: "https://books.toscrape.com", judge_context: ["Output must contain 5 books", "Each book must have a title and a price"] },
|
|
{ name: "list-quotes-3", task: "Extract the first 3 quotes with their text and author", url: "https://quotes.toscrape.com", judge_context: ["Output must contain 3 quotes", "Each quote must have text and an author name"] },
|
|
{ name: "list-github-trending", task: "Extract the top 3 trending repositories with name and description", url: "https://github.com/trending", judge_context: ["Output must contain 3 repositories", "Each must have a repo name"] },
|
|
{ name: "list-jsonplaceholder", task: "Extract the first 5 posts with their title", url: "https://jsonplaceholder.typicode.com/posts", judge_context: ["Output must contain 5 posts", "Each post must have a title"] },
|
|
|
|
// Search
|
|
{ name: "search-ddg", task: "Search for 'TypeScript tutorial' and extract the first 3 result titles", url: "https://duckduckgo.com", judge_context: ["The agent must type a search query", "Output must contain at least 3 search result titles"] },
|
|
{ name: "search-npm", task: "Search for 'react' and extract the top 3 package names", url: "https://www.npmjs.com", judge_context: ["The agent must search for 'react'", "Output must contain at least 3 package names"] },
|
|
{ name: "search-wiki", task: "Search for 'Rust programming language' and extract the first sentence of the article", url: "https://en.wikipedia.org", judge_context: ["The agent must search and navigate to the article", "Output must mention 'programming language'"] },
|
|
|
|
// Navigation
|
|
{ name: "nav-click-link", task: "Click the 'More information...' link and extract the heading of the new page", url: "https://example.com", judge_context: ["The agent must click a link", "Output must contain 'IANA' or reference the new page"] },
|
|
{ name: "nav-click-hn", task: "Click on the first story link and tell me the title of the page you land on", url: "https://news.ycombinator.com", judge_context: ["The agent must click a story link", "Output must contain the title of the destination page"] },
|
|
{ name: "nav-go-back", task: "Click the 'More information...' link, then go back, and tell me the heading of the original page", url: "https://example.com", judge_context: ["The agent must click a link then go back", "Output must contain 'Example Domain'"] },
|
|
{ name: "nav-multi-step", task: "Click the Next page link at the bottom, then extract the first quote from page 2", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain a quote from page 2"] },
|
|
|
|
// Scroll
|
|
{ name: "scroll-footer", task: "Scroll to the bottom and extract the footer text", url: "https://quotes.toscrape.com", judge_context: ["The agent must scroll down", "Output must contain footer or bottom-of-page content"] },
|
|
{ name: "scroll-pagination", task: "Find the pagination info at the bottom of the page", url: "https://books.toscrape.com", judge_context: ["Output must contain page number or pagination info"] },
|
|
|
|
// Form
|
|
{ name: "form-fill-basic", task: "Fill the Customer Name with 'OpenCLI' and Telephone with '555-0100'. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must type 'OpenCLI' into a name field", "The agent must type '555-0100' into a phone field", "The form must NOT be submitted"] },
|
|
{ name: "form-radio", task: "Select the 'Medium' pizza size option. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must select a radio button for Medium size"] },
|
|
{ name: "form-login", task: "Fill the username with 'testuser' and password with 'testpass'. Do not submit.", url: "https://the-internet.herokuapp.com/login", judge_context: ["The agent must fill the username field", "The agent must fill the password field", "The form must NOT be submitted"] },
|
|
|
|
// Complex
|
|
{ name: "complex-wiki-toc", task: "Extract the table of contents headings", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must contain at least 5 section headings from the table of contents"] },
|
|
{ name: "complex-books-detail", task: "Click on the first book and extract its title and price from the detail page", url: "https://books.toscrape.com", judge_context: ["The agent must click on a book", "Output must contain the book title", "Output must contain a price"] },
|
|
{ name: "complex-quotes-page2", task: "Navigate to page 2 and extract the first 3 quotes with their authors", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain 3 quotes with authors"] },
|
|
{ name: "complex-multi-extract", task: "Extract both the page title and the first paragraph text", url: "https://en.wikipedia.org/wiki/TypeScript", judge_context: ["Output must contain 'TypeScript'", "Output must contain actual paragraph text"] },
|
|
|
|
// Bench (harder, real-world)
|
|
{ name: "bench-reddit", task: "Extract the titles of the top 5 posts", url: "https://old.reddit.com", judge_context: ["Output must contain 5 post titles", "Titles must be actual Reddit posts"] },
|
|
{ name: "bench-imdb", task: "Find the year and rating of The Matrix", url: "https://www.imdb.com/title/tt0133093/", judge_context: ["Output must contain '1999'", "Output must contain a rating number"] },
|
|
{ name: "bench-github-profile", task: "Extract the bio and number of public repositories", url: "https://github.com/torvalds", judge_context: ["Output must contain bio text or 'Linux'", "Output must contain a number for repos"] },
|
|
{ name: "bench-httpbin", task: "Extract the User-Agent header shown on this page", url: "https://httpbin.org/headers", judge_context: ["Output must contain a User-Agent string"] },
|
|
{ name: "bench-jsonapi-todo", task: "Extract the first 5 todo items with their title and completion status", url: "https://jsonplaceholder.typicode.com/todos", judge_context: ["Output must contain 5 todo items", "Each must have a title and completed status"] },
|
|
|
|
// Codex form (the real test)
|
|
{ name: "codex-form-fill", task: "Fill the basic information using 'opencli' as the identity (first name=open, last name=cli, email=opencli@example.com, GitHub username=opencli). Do NOT submit the form.", url: "https://openai.com/form/codex-for-oss/", judge_context: ["The agent must fill the first name field", "The agent must fill the last name field", "The agent must fill the email field", "The form must NOT be submitted"], max_steps: 15 },
|
|
];
|
|
|
|
// ── Run Task ───────────────────────────────────────────────────────
|
|
|
|
function runSkillTask(task: SkillTask): TaskResult {
|
|
const start = Date.now();
|
|
const skillContent = readFileSync(SKILL_PATH, 'utf-8');
|
|
const urlPart = task.url ? ` Start URL: ${task.url}` : '';
|
|
const criteria = task.judge_context.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
|
|
|
const prompt = `Complete this browser task using opencli operate commands:
|
|
|
|
TASK: ${task.task}${urlPart}
|
|
|
|
After completing the task, evaluate your own result against these criteria:
|
|
${criteria}
|
|
|
|
At the very end of your response, output a JSON verdict on its own line:
|
|
{"success": true/false, "explanation": "brief explanation"}
|
|
|
|
Always close the browser with 'opencli operate close' when done.`;
|
|
|
|
try {
|
|
const output = execSync(
|
|
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*)" --system-prompt ${JSON.stringify(skillContent)} --output-format json --no-session-persistence ${JSON.stringify(prompt)}`,
|
|
{
|
|
cwd: join(__dirname, '..'),
|
|
timeout: (task.max_steps ?? 10) * 15_000,
|
|
encoding: 'utf-8',
|
|
env: process.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}
|
|
);
|
|
|
|
const duration = Date.now() - start;
|
|
|
|
// Parse Claude Code output
|
|
let resultText = '';
|
|
let cost = 0;
|
|
try {
|
|
const parsed = JSON.parse(output);
|
|
resultText = parsed.result ?? output;
|
|
cost = parsed.total_cost_usd ?? 0;
|
|
} catch {
|
|
resultText = output;
|
|
}
|
|
|
|
// Extract verdict JSON from the result
|
|
const verdict = extractVerdict(resultText);
|
|
|
|
return {
|
|
name: task.name,
|
|
passed: verdict.success,
|
|
duration,
|
|
cost,
|
|
explanation: verdict.explanation,
|
|
};
|
|
} catch (err: any) {
|
|
return {
|
|
name: task.name,
|
|
passed: false,
|
|
duration: Date.now() - start,
|
|
cost: 0,
|
|
explanation: (err.stdout ?? err.message ?? 'timeout or crash').slice(0, 200),
|
|
};
|
|
}
|
|
}
|
|
|
|
function extractVerdict(text: string): { success: boolean; explanation: string } {
|
|
// Try to find and parse {"success": ...} JSON from the last occurrence
|
|
const idx = text.lastIndexOf('{"success"');
|
|
if (idx !== -1) {
|
|
// Find the matching closing brace (handle escaped quotes in explanation)
|
|
const sub = text.slice(idx);
|
|
let braceCount = 0;
|
|
let end = -1;
|
|
for (let i = 0; i < sub.length; i++) {
|
|
if (sub[i] === '{') braceCount++;
|
|
else if (sub[i] === '}') { braceCount--; if (braceCount === 0) { end = i + 1; break; } }
|
|
}
|
|
if (end > 0) {
|
|
try { return JSON.parse(sub.slice(0, end)); } catch { /* fall through */ }
|
|
}
|
|
}
|
|
|
|
// Fallback: check for success indicators in text
|
|
const lower = text.toLowerCase();
|
|
if (lower.includes('"success": true') || lower.includes('"success":true')) {
|
|
return { success: true, explanation: 'Parsed success from output' };
|
|
}
|
|
if (lower.includes('"success": false') || lower.includes('"success":false')) {
|
|
return { success: false, explanation: 'Parsed failure from output' };
|
|
}
|
|
|
|
// Final fallback: assume failure if we can't parse
|
|
return { success: false, explanation: 'Could not parse verdict from output' };
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
|
const tasks = singleTask ? TASKS.filter(t => t.name === singleTask) : TASKS;
|
|
|
|
if (tasks.length === 0) {
|
|
console.error(`Task "${singleTask}" not found. Available: ${TASKS.map(t => t.name).join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\n🔬 Layer 2: Skill E2E (LLM Judge) — ${tasks.length} tasks\n`);
|
|
|
|
const results: TaskResult[] = [];
|
|
|
|
for (let i = 0; i < tasks.length; i++) {
|
|
const task = tasks[i];
|
|
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
|
|
|
const result = runSkillTask(task);
|
|
results.push(result);
|
|
|
|
const icon = result.passed ? '✓' : '✗';
|
|
const costStr = result.cost > 0 ? `, $${result.cost.toFixed(2)}` : '';
|
|
console.log(` ${icon} (${Math.round(result.duration / 1000)}s${costStr})`);
|
|
}
|
|
|
|
// Summary
|
|
const totalPassed = results.filter(r => r.passed).length;
|
|
const totalCost = results.reduce((s, r) => s + r.cost, 0);
|
|
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
|
|
|
console.log(`\n${'─'.repeat(50)}`);
|
|
console.log(` Score: ${totalPassed}/${results.length} (${Math.round(totalPassed / results.length * 100)}%)`);
|
|
console.log(` Cost: $${totalCost.toFixed(2)}`);
|
|
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
|
|
|
const failures = results.filter(r => !r.passed);
|
|
if (failures.length > 0) {
|
|
console.log(`\n Failures:`);
|
|
for (const f of failures) {
|
|
console.log(` ✗ ${f.name}: ${f.explanation}`);
|
|
}
|
|
}
|
|
console.log('');
|
|
|
|
// Save
|
|
mkdirSync(RESULTS_DIR, { recursive: true });
|
|
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('skill-')).length;
|
|
const roundNum = String(existing + 1).padStart(3, '0');
|
|
const resultPath = join(RESULTS_DIR, `skill-${roundNum}.json`);
|
|
writeFileSync(resultPath, JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
score: `${totalPassed}/${results.length}`,
|
|
totalCost,
|
|
duration: `${Math.round(totalDuration / 60000)}min`,
|
|
tasks: results,
|
|
}, null, 2), 'utf-8');
|
|
console.log(` Results saved to: ${resultPath}`);
|
|
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
|
}
|
|
|
|
main();
|