Files
jackwener__opencli/src/generate-verified.test.ts
jakevin 70b1145b5e refactor: migrate all CLI adapters from YAML to TypeScript (#887)
* refactor: remove version field from GenerateOutcome and EarlyHint

All consumers are in the same repo and evolve together — version field
adds ceremony without practical value at this stage.

Keeps schema_version in VerifiedArtifactMetadata (sidecar file format).

* refactor: migrate all 123 CLI adapters from YAML to TypeScript

Remove YAML as an adapter format entirely. All adapters now use
TypeScript with cli() from @jackwener/opencli/registry.

- Convert 123 YAML adapter files to TypeScript via batch script
- Remove YAML scanning from discovery.ts (registerYamlCli, yaml import)
- Remove scanYaml() and shouldReplaceManifestEntry() from build-manifest.ts
- Change synthesize.ts to output JSON candidates (internal format)
- Change generate-verified.ts to write .ts adapter files instead of .yaml
- Delete yaml-schema.ts (dead code) and scripts/yaml-to-ts.mjs (one-time tool)
- Update all tests to match new format

Closes discussion in #OpenCLI thread 47ddba82.

* fix: close YAML migration gaps in plugin scaffold, validation, and scan

- plugin-scaffold.ts: generate hello.ts (TS pipeline) instead of hello.yaml
- plugin.ts validatePluginStructure: no longer accept .yaml as valid command file
- plugin.ts scanPluginCommands: remove .yaml/.yml from scanned extensions
- discovery.ts: add explicit log.warn() when YAML files detected in clis/ or plugins/
- plugin.test.ts: update all test fixtures from .yaml to .js
- plugin-scaffold.test.ts: update hello.yaml references to hello.ts
- Delete dead src/yaml-schema.ts

Resolves PR #887 review blockers from @mbp-codex-pr0.

* refactor: complete YAML removal across docs, skills, record, and binance adapters

Code changes:
- record.ts: candidate output changed from .yaml (yaml.dump) to .json (JSON.stringify), removed js-yaml import
- src/clis/binance: convert all 11 YAML adapters to TypeScript cli() format
- binance/commands.test.ts: rewrite to use registry instead of yaml.load
- skill-generate.test.ts, diagnostic.test.ts: update mock paths from .yaml to .ts
- build-manifest.ts, synthesize.ts: update stale YAML comments

Documentation:
- README.md: remove .yaml from Dynamic Loader, fix plugin types, fix synthesize comment
- README.zh-CN.md: fix synthesize comment
- CONTRIBUTING.md: replace YAML Adapter section with Pipeline Adapter (TS), update arg examples
- docs/developer/yaml-adapter.md: replaced with deprecation redirect
- docs/developer/architecture.md: remove YAML pipeline references
- docs/developer/contributing.md: remove YAML adapter section
- docs/developer/ai-workflow.md: YAML → TS in synthesize description
- docs/guide/getting-started.md: remove .yaml from loader, update engine description
- docs/guide/plugins.md: remove YAML plugin option, update plugin types
- docs/index.md, docs/comparison.md: remove YAML adapter references
- docs/zh/guide/plugins.md: remove .yaml from scan description

Skills:
- opencli-explorer/SKILL.md: rewrite YAML vs TS decision tree to TS-only
- opencli-oneshot/SKILL.md: replace YAML templates with TS cli() templates
- opencli-generate/SKILL.md: YAML artifact path → TS artifact path
- opencli-usage/SKILL.md, plugins.md: update adapter format references

* fix: clean up remaining YAML adapter references in docs

- docs/zh/guide/plugins.md: replace YAML plugin example with TS pipeline
- docs/developer/testing.md: YAML Adapter heading → Adapter, remove validate line
- TESTING.md: same fix in root testing doc
- CONTRIBUTING.md: remove "YAML validation" comment
- docs/.vitepress/config.mts: mark YAML Adapter Guide as (Deprecated) in nav
- docs/advanced/download.md: remove "YAML Adapters" from pipeline step heading
2026-04-08 23:01:08 +08:00

1006 lines
34 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { IPage } from './types.js';
const {
mockExploreUrl,
mockLoadExploreBundle,
mockSynthesizeFromExplore,
mockBrowserSession,
mockCascadeProbe,
mockExecutePipeline,
mockRegisterCommand,
} = vi.hoisted(() => ({
mockExploreUrl: vi.fn(),
mockLoadExploreBundle: vi.fn(),
mockSynthesizeFromExplore: vi.fn(),
mockBrowserSession: vi.fn(),
mockCascadeProbe: vi.fn(),
mockExecutePipeline: vi.fn(),
mockRegisterCommand: vi.fn(),
}));
vi.mock('./explore.js', () => ({
exploreUrl: mockExploreUrl,
}));
vi.mock('./synthesize.js', () => ({
loadExploreBundle: mockLoadExploreBundle,
synthesizeFromExplore: mockSynthesizeFromExplore,
}));
vi.mock('./runtime.js', () => ({
browserSession: mockBrowserSession,
}));
vi.mock('./cascade.js', () => ({
cascadeProbe: mockCascadeProbe,
}));
vi.mock('./pipeline/index.js', () => ({
executePipeline: mockExecutePipeline,
}));
vi.mock('./registry.js', async () => {
const actual = await vi.importActual<typeof import('./registry.js')>('./registry.js');
return {
...actual,
registerCommand: mockRegisterCommand,
};
});
vi.mock('./discovery.js', () => ({
USER_CLIS_DIR: '/tmp/opencli-user-clis',
}));
import { Strategy } from './registry.js';
import { generateVerifiedFromUrl, type GenerateOutcome, type EarlyHint } from './generate-verified.js';
describe('generateVerifiedFromUrl', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-generate-verified-'));
mockExploreUrl.mockReset();
mockLoadExploreBundle.mockReset();
mockSynthesizeFromExplore.mockReset();
mockBrowserSession.mockReset();
mockCascadeProbe.mockReset();
mockExecutePipeline.mockReset();
mockRegisterCommand.mockReset();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ── Blocked outcomes ──────────────────────────────────────────────────────
it('returns blocked with no-viable-api-surface when discover finds no API endpoints', async () => {
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 0,
capabilities: [],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [],
capabilities: [],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 0,
candidates: [],
});
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
});
expect(result.status).toBe('blocked');
expect(result.reason).toBe('no-viable-api-surface');
expect(result.stage).toBe('explore');
expect(result.confidence).toBe('high');
expect(result.message).toBeDefined();
expect(result.stats.api_endpoint_count).toBe(0);
expect(mockBrowserSession).not.toHaveBeenCalled();
});
it('returns blocked with auth-too-complex when no PUBLIC/COOKIE probe succeeds', async () => {
const candidatePath = path.join(tempDir, 'hot.json');
fs.writeFileSync(candidatePath, JSON.stringify({
site: 'demo',
name: 'hot',
description: 'demo hot',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/hot' } },
{ select: 'data.items' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'cookie',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'hot' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'hot', strategy: 'cookie', endpoint: 'demo.test/api/hot', itemPath: 'data.items' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'hot', path: candidatePath, strategy: 'public' }],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.COOKIE,
probes: [
{ strategy: Strategy.PUBLIC, success: false },
{ strategy: Strategy.COOKIE, success: false },
],
confidence: 0.3,
});
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
});
expect(mockExecutePipeline).not.toHaveBeenCalled();
expect(result.status).toBe('blocked');
expect(result.reason).toBe('auth-too-complex');
expect(result.stage).toBe('cascade');
expect(result.confidence).toBe('high');
});
// ── Success outcomes ──────────────────────────────────────────────────────
it('verifies the selected candidate in a single session and registers on success with sidecar metadata', async () => {
const hotPath = path.join(tempDir, 'hot.json');
const searchPath = path.join(tempDir, 'search.json');
fs.writeFileSync(hotPath, JSON.stringify({
site: 'demo',
name: 'hot',
description: 'demo hot',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
limit: { type: 'int', default: 20 },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/hot?limit=${{ args.limit | default(20) }}' } },
{ select: 'data.items' },
{ map: { rank: '${{ index + 1 }}', title: '${{ item.title }}', url: '${{ item.url }}' } },
{ limit: '${{ args.limit | default(20) }}' },
],
}, null, 2));
fs.writeFileSync(searchPath, JSON.stringify({
site: 'demo',
name: 'search',
description: 'demo search',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
keyword: { type: 'str', required: true },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/search?q=${{ args.keyword }}' } },
{ select: 'payload.items' },
{ map: { title: '${{ item.title }}', url: '${{ item.url }}' } },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test/home',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'cookie',
endpoint_count: 2,
api_endpoint_count: 2,
capabilities: [{ name: 'hot' }, { name: 'search' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test/home' },
endpoints: [
{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot?limit=20',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title', url: 'url' },
},
{
pattern: 'demo.test/api/search',
url: 'https://demo.test/api/search?q=test',
itemPath: 'payload.items',
itemCount: 10,
detectedFields: { title: 'headline', url: 'permalink' },
},
],
capabilities: [
{ name: 'hot', strategy: 'public', endpoint: 'demo.test/api/hot', itemPath: 'data.items' },
{ name: 'search', strategy: 'cookie', endpoint: 'demo.test/api/search', itemPath: 'payload.items' },
],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 2,
candidates: [
{ name: 'hot', path: hotPath, strategy: 'public' },
{ name: 'search', path: searchPath, strategy: 'public' },
],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.COOKIE,
probes: [
{ strategy: Strategy.PUBLIC, success: false },
{ strategy: Strategy.COOKIE, success: true },
],
confidence: 0.9,
});
mockExecutePipeline.mockResolvedValue([{ title: 'hello', url: 'https://demo.test/item/1' }]);
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
goal: 'search',
noRegister: false,
});
expect(mockBrowserSession).toHaveBeenCalledTimes(1);
expect(page.goto).toHaveBeenCalledWith('https://demo.test/home');
expect(mockCascadeProbe).toHaveBeenCalledWith(page, 'https://demo.test/api/search?q=test', { maxStrategy: Strategy.COOKIE });
expect(mockExecutePipeline).toHaveBeenCalledTimes(1);
expect(mockRegisterCommand).toHaveBeenCalledTimes(1);
expect(result.status).toBe('success');
expect(result.adapter).toBeDefined();
expect(result.adapter!.command).toBe('demo/search');
expect(result.adapter!.strategy).toBe(Strategy.COOKIE);
expect(result.adapter!.metadata_path).toBeDefined();
expect(result.adapter!.reusability).toBe('verified-artifact');
expect(result.reusability).toBe('verified-artifact');
expect(result.stats.verified).toBe(true);
expect(result.stats.repair_attempted).toBe(false);
// Verify sidecar metadata was written
expect(result.adapter!.metadata_path).toMatch(/\.meta\.json$/);
const metaContent = JSON.parse(fs.readFileSync(result.adapter!.metadata_path!, 'utf-8'));
expect(metaContent).toEqual(expect.objectContaining({
artifact_kind: 'verified',
schema_version: 1,
source_url: 'https://demo.test',
strategy: Strategy.COOKIE,
verified: true,
reusable: true,
reusability_reason: 'verified-artifact',
}));
});
it('writes verified artifact + sidecar metadata for --no-register success', async () => {
const candidatePath = path.join(tempDir, 'search.json');
fs.writeFileSync(candidatePath, JSON.stringify({
site: 'demo',
name: 'search',
description: 'demo search',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
keyword: { type: 'str', required: true },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/search?q=${{ args.keyword }}' } },
{ select: 'payload.items' },
{ map: { title: '${{ item.title }}', url: '${{ item.url }}' } },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test/home',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'cookie',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'search' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test/home' },
endpoints: [{
pattern: 'demo.test/api/search',
url: 'https://demo.test/api/search?q=test',
itemPath: 'payload.items',
itemCount: 10,
detectedFields: { title: 'headline', url: 'permalink' },
}],
capabilities: [{ name: 'search', strategy: 'cookie', endpoint: 'demo.test/api/search', itemPath: 'payload.items' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'search', path: candidatePath, strategy: 'public' }],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.COOKIE,
probes: [
{ strategy: Strategy.PUBLIC, success: false },
{ strategy: Strategy.COOKIE, success: true },
],
confidence: 0.9,
});
mockExecutePipeline.mockResolvedValue([{ title: 'hello', url: 'https://demo.test/item/1' }]);
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
goal: 'search',
noRegister: true,
});
expect(result.status).toBe('success');
expect(
path.normalize(result.adapter!.path).endsWith(
path.join('verified', 'search.verified.ts'),
),
).toBe(true);
expect(result.adapter?.path).not.toBe(candidatePath);
expect(
path.normalize(result.adapter!.metadata_path!).endsWith(
path.join('verified', 'search.verified.meta.json'),
),
).toBe(true);
expect(fs.existsSync(result.adapter!.path)).toBe(true);
expect(fs.existsSync(result.adapter!.metadata_path!)).toBe(true);
expect(mockRegisterCommand).not.toHaveBeenCalled();
});
// ── needs-human-check outcomes ────────────────────────────────────────────
it('returns needs-human-check with structured escalation when repair exhausted', async () => {
const candidatePath = path.join(tempDir, 'hot.json');
fs.writeFileSync(candidatePath, JSON.stringify({
site: 'demo',
name: 'hot',
description: 'demo hot',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
limit: { type: 'int', default: 20 },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/hot?limit=${{ args.limit | default(20) }}' } },
{ select: 'wrong.items' },
{ map: { rank: '${{ index + 1 }}', title: '${{ item.title }}', url: '${{ item.url }}' } },
{ limit: '${{ args.limit | default(20) }}' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'hot' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot?limit=20',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'hot', strategy: 'public', endpoint: 'demo.test/api/hot', itemPath: 'data.items' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'hot', path: candidatePath, strategy: 'public' }],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.PUBLIC,
probes: [{ strategy: Strategy.PUBLIC, success: true }],
confidence: 1,
});
mockExecutePipeline.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
});
expect(mockExecutePipeline).toHaveBeenCalledTimes(2);
expect(mockExecutePipeline.mock.calls[0]?.[1]).toEqual(expect.arrayContaining([{ select: 'wrong.items' }]));
expect(mockExecutePipeline.mock.calls[1]?.[1]).toEqual(expect.arrayContaining([{ select: 'data.items' }]));
// Verify structured escalation contract
expect(result.status).toBe('needs-human-check');
expect(result.escalation).toBeDefined();
expect(result.escalation!.stage).toBe('fallback');
expect(result.escalation!.reason).toBe('empty-result');
expect(result.escalation!.confidence).toBe('low');
expect(result.escalation!.suggested_action).toBe('inspect-with-browser');
expect(result.escalation!.candidate).toBeDefined();
expect(result.escalation!.candidate.command).toBe('demo/hot');
expect(result.escalation!.candidate.reusability).toBe('unverified-candidate');
expect(result.reusability).toBe('unverified-candidate');
expect(result.message).toContain('Repair exhausted');
expect(result.stats.repair_attempted).toBe(true);
expect(result.stats.verified).toBe(false);
});
it('returns needs-human-check with ask-for-sample-arg for unsupported required args', async () => {
const candidatePath = path.join(tempDir, 'detail.json');
fs.writeFileSync(candidatePath, JSON.stringify({
site: 'demo',
name: 'detail',
description: 'demo detail',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
id: { type: 'str', required: true },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/detail?id=${{ args.id }}' } },
{ select: 'data.item' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test/detail/123',
final_url: 'https://demo.test/detail/123',
title: 'Demo detail',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'detail' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test/detail/123', final_url: 'https://demo.test/detail/123' },
endpoints: [{
pattern: 'demo.test/api/detail',
url: 'https://demo.test/api/detail?id=123',
itemPath: 'data.item',
itemCount: 1,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'detail', strategy: 'public', endpoint: 'demo.test/api/detail', itemPath: 'data.item' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'detail', path: candidatePath, strategy: 'public' }],
});
const result = await generateVerifiedFromUrl({
url: 'https://demo.test/detail/123',
BrowserFactory: class {} as never,
goal: 'detail',
noRegister: true,
});
expect(mockBrowserSession).not.toHaveBeenCalled();
expect(result.status).toBe('needs-human-check');
expect(result.escalation).toBeDefined();
expect(result.escalation!.stage).toBe('synthesize');
expect(result.escalation!.reason).toBe('unsupported-required-args');
expect(result.escalation!.confidence).toBe('high');
expect(result.escalation!.suggested_action).toBe('ask-for-sample-arg');
expect(result.escalation!.candidate.reusability).toBe('unverified-candidate');
expect(result.reusability).toBe('unverified-candidate');
expect(result.message).toContain('required args: id');
});
// ── Contract shape validation ─────────────────────────────────────────────
it('all outcome statuses include status and stats', async () => {
// Test the blocked path - simplest to set up
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 0,
api_endpoint_count: 0,
capabilities: [],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [],
capabilities: [],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 0,
candidates: [],
});
const result: GenerateOutcome = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
});
// Every outcome must have these three fields
expect(result).toHaveProperty('status');
expect(result).toHaveProperty('stats');
expect(['success', 'blocked', 'needs-human-check']).toContain(result.status);
// Blocked must have stage + reason + confidence
if (result.status === 'blocked') {
expect(result).toHaveProperty('reason');
expect(result).toHaveProperty('stage');
expect(result).toHaveProperty('confidence');
}
});
// ── P2: EarlyHint callback ────────────────────────────────────────────────
it('emits explore stop hint when no API surface found', async () => {
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 0,
capabilities: [],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [],
capabilities: [],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 0,
candidates: [],
});
const hints: EarlyHint[] = [];
await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
onEarlyHint: (h) => hints.push(h),
});
expect(hints).toHaveLength(1);
expect(hints[0]).toEqual({
stage: 'explore',
continue: false,
reason: 'no-viable-api-surface',
confidence: 'high',
});
});
it('emits explore continue + synthesize stop hints when no candidate found', async () => {
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title' },
}],
capabilities: [],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 0,
candidates: [],
});
const hints: EarlyHint[] = [];
await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
onEarlyHint: (h) => hints.push(h),
});
expect(hints).toHaveLength(2);
expect(hints[0]).toMatchObject({ stage: 'explore', continue: true, reason: 'api-surface-looks-viable' });
expect(hints[1]).toMatchObject({ stage: 'synthesize', continue: false, reason: 'no-viable-candidate' });
});
it('emits explore + synthesize continue hints with candidate on success path', async () => {
const hotPath = path.join(tempDir, 'hot.json');
fs.writeFileSync(hotPath, JSON.stringify({
site: 'demo',
name: 'hot',
description: 'demo hot',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/hot' } },
{ select: 'data.items' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'hot' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'hot', strategy: 'public', endpoint: 'demo.test/api/hot', itemPath: 'data.items' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'hot', path: hotPath, strategy: 'public' }],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.PUBLIC,
probes: [{ strategy: Strategy.PUBLIC, success: true }],
confidence: 1.0,
});
mockExecutePipeline.mockResolvedValue([
{ title: 'Post 1', url: 'https://demo.test/1' },
{ title: 'Post 2', url: 'https://demo.test/2' },
]);
const hints: EarlyHint[] = [];
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
onEarlyHint: (h) => hints.push(h),
});
expect(result.status).toBe('success');
expect(hints).toHaveLength(3);
expect(hints[0]).toMatchObject({ stage: 'explore', continue: true });
expect(hints[1]).toMatchObject({
stage: 'synthesize',
continue: true,
reason: 'candidate-ready-for-verify',
candidate: { name: 'hot', command: 'demo/hot', reusability: 'unverified-candidate' },
});
expect(hints[2]).toMatchObject({
stage: 'cascade',
continue: true,
reason: 'candidate-ready-for-verify',
candidate: { name: 'hot', command: 'demo/hot' },
});
// No candidate on explore hint
expect(hints[0]).not.toHaveProperty('candidate');
});
it('emits cascade stop hint when auth-too-complex', async () => {
const hotPath = path.join(tempDir, 'hot.json');
fs.writeFileSync(hotPath, JSON.stringify({
site: 'demo',
name: 'hot',
description: 'demo hot',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/hot' } },
{ select: 'data.items' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'cookie',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'hot' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [{
pattern: 'demo.test/api/hot',
url: 'https://demo.test/api/hot',
itemPath: 'data.items',
itemCount: 5,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'hot', strategy: 'cookie', endpoint: 'demo.test/api/hot', itemPath: 'data.items' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'hot', path: hotPath, strategy: 'public' }],
});
const page = { goto: vi.fn() } as unknown as IPage;
mockBrowserSession.mockImplementation(async (_factory, fn) => fn(page));
mockCascadeProbe.mockResolvedValue({
bestStrategy: Strategy.COOKIE,
probes: [
{ strategy: Strategy.PUBLIC, success: false },
{ strategy: Strategy.COOKIE, success: false },
],
confidence: 0.3,
});
const hints: EarlyHint[] = [];
await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
onEarlyHint: (h) => hints.push(h),
});
expect(hints).toHaveLength(3);
expect(hints[0]).toMatchObject({ stage: 'explore', continue: true });
expect(hints[1]).toMatchObject({ stage: 'synthesize', continue: true, reason: 'candidate-ready-for-verify' });
expect(hints[2]).toMatchObject({ stage: 'cascade', continue: false, reason: 'auth-too-complex' });
// No candidate on stop hint
expect(hints[2]).not.toHaveProperty('candidate');
});
it('does NOT emit P2 hint for unsupported-required-args (P1-only decision)', async () => {
const detailPath = path.join(tempDir, 'detail.json');
fs.writeFileSync(detailPath, JSON.stringify({
site: 'demo',
name: 'detail',
description: 'demo detail',
domain: 'demo.test',
strategy: 'public',
browser: false,
args: {
id: { type: 'str', required: true },
},
columns: ['title', 'url'],
pipeline: [
{ fetch: { url: 'https://demo.test/api/detail?id=${{ args.id }}' } },
{ select: 'data.item' },
],
}, null, 2));
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test/detail/123',
final_url: 'https://demo.test/detail/123',
title: 'Demo detail',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 1,
capabilities: [{ name: 'detail' }],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test/detail/123', final_url: 'https://demo.test/detail/123' },
endpoints: [{
pattern: 'demo.test/api/detail',
url: 'https://demo.test/api/detail?id=123',
itemPath: 'data.item',
itemCount: 1,
detectedFields: { title: 'title', url: 'url' },
}],
capabilities: [{ name: 'detail', strategy: 'public', endpoint: 'demo.test/api/detail', itemPath: 'data.item' }],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 1,
candidates: [{ name: 'detail', path: detailPath, strategy: 'public' }],
});
const hints: EarlyHint[] = [];
const result = await generateVerifiedFromUrl({
url: 'https://demo.test/detail/123',
BrowserFactory: class {} as never,
goal: 'detail',
noRegister: true,
onEarlyHint: (h) => hints.push(h),
});
expect(result.status).toBe('needs-human-check');
expect(result.escalation!.reason).toBe('unsupported-required-args');
// Only explore continue hint is emitted; NO synthesize hint before the P1 terminal
expect(hints).toHaveLength(1);
expect(hints[0]).toMatchObject({ stage: 'explore', continue: true });
// Specifically: no synthesize hint was emitted
expect(hints.find(h => h.stage === 'synthesize')).toBeUndefined();
});
it('does not emit hints when onEarlyHint is not provided', async () => {
mockExploreUrl.mockResolvedValue({
site: 'demo',
target_url: 'https://demo.test',
final_url: 'https://demo.test',
title: 'Demo',
framework: {},
stores: [],
top_strategy: 'public',
endpoint_count: 1,
api_endpoint_count: 0,
capabilities: [],
auth_indicators: [],
out_dir: tempDir,
});
mockLoadExploreBundle.mockReturnValue({
manifest: { site: 'demo', target_url: 'https://demo.test', final_url: 'https://demo.test' },
endpoints: [],
capabilities: [],
});
mockSynthesizeFromExplore.mockReturnValue({
site: 'demo',
explore_dir: tempDir,
out_dir: tempDir,
candidate_count: 0,
candidates: [],
});
// Should not throw even without onEarlyHint
const result = await generateVerifiedFromUrl({
url: 'https://demo.test',
BrowserFactory: class {} as never,
noRegister: true,
});
expect(result.status).toBe('blocked');
});
});