Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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-adapter-author 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-adapter-author', '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 browser 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 browser 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();