test(pen-mcp): composition — N-tool real screens (6 scenarios)

Per-tool handler tests cover "this tool emits the correct shape"
individually. This file covers the next layer: can N element-tool
calls chain together into a realistic multi-section screen without
breaking tree invariants?

Scenarios (each spans multiple tool families to catch cross-
family regressions):
  1. Mobile settings — top_nav + 2 sections × 3 list_rows + bottom_nav (10 calls)
  2. Dashboard home — top_nav + stat_grid + section + 3 metric_comparisons + chart (7)
  3. Login form — heading + body + 2 form_fields + button + link (6)
  4. Profile + UGC — top_nav + avatar + heading + badge + 2 faq_items + action_menu (7)
  5. Listing — search + card_row + divider + empty_chart + date_picker + chip_input + pagination (7)
  6. parent_id threading invariant — nested insert actually lands under named parent

Each scenario asserts:
  - Every call emits a nodeId (no silent no-ops)
  - Final document parses as valid JSON with expected root children count
  - Every call's nodeId is findable in the saved tree
  - Every tool's canonical role survives post-save
  - parent_id threading works (child lands under named parent, not root)

This is the integration gate that catches "tool wiring works
individually but composes wrong" — the ghost regression that can
slip past per-tool tests.
This commit is contained in:
Fini 2026-04-22 09:50:00 +08:00
parent 4a75b53c45
commit df9f37522f

View file

@ -0,0 +1,430 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { invalidateCache } from '../document-manager';
import { handleAddTopNavBarV0 } from '../tools/add-top-nav-bar-v0';
import { handleAddBottomNavV0 } from '../tools/add-bottom-nav-v0';
import { handleAddSectionHeaderV0 } from '../tools/add-section-header-v0';
import { handleAddListRowV0 } from '../tools/add-list-row-v0';
import { handleAddStatGridV0 } from '../tools/add-stat-grid-v0';
import { handleAddMetricComparisonV0 } from '../tools/add-metric-comparison-v0';
import { handleAddChartBarsV0 } from '../tools/add-chart-bars-v0';
import { handleAddHeadingV0 } from '../tools/add-heading-v0';
import { handleAddBodyTextV0 } from '../tools/add-body-text-v0';
import { handleAddFormFieldV0 } from '../tools/add-form-field-v0';
import { handleAddTextButtonV0 } from '../tools/add-text-button-v0';
import { handleAddLinkV0 } from '../tools/add-link-v0';
import { handleAddAvatarV0 } from '../tools/add-avatar-v0';
import { handleAddBadgeV0 } from '../tools/add-badge-v0';
import { handleAddSearchBarV0 } from '../tools/add-search-bar-v0';
import { handleAddCardRowV0 } from '../tools/add-card-row-v0';
import { handleAddDividerV0 } from '../tools/add-divider-v0';
import { handleAddPaginationV0 } from '../tools/add-pagination-v0';
import { handleAddFaqItemV0 } from '../tools/add-faq-item-v0';
import { handleAddActionMenuV0 } from '../tools/add-action-menu-v0';
import { handleAddDatePickerV0 } from '../tools/add-date-picker-v0';
import { handleAddEmptyChartV0 } from '../tools/add-empty-chart-v0';
import { handleAddChipInputV0 } from '../tools/add-chip-input-v0';
/**
* End-to-end composition tests — verifies that N element-tool calls
* can be chained together with parent_id threading to produce a
* realistic multi-section screen without breaking tree invariants.
*
* Per-tool handler tests already cover "this tool emits the correct
* shape." This file covers the next layer up: "can the AI take the
* output of tool A, use its nodeId as parent_id for tool B, and
* produce a well-formed screen across N such calls?" That's the
* actual usage pattern under batch orchestration.
*
* Invariants checked after each composition:
* 1. No call throws
* 2. Every result includes a nodeId (no silent no-ops)
* 3. Final document is valid JSON with a parseable tree
* 4. Every tool call's nodeId is findable in the final tree
* 5. parent_id threading works — a child's actual parent in the
* saved tree matches the parent_id we passed in
* 6. Every tool's role marker survives into the final tree
*
* Screens intentionally span tool families (nav + content + inputs +
* charts + feedback + pagination) so we catch cross-family regressions.
*/
const TMP = join(tmpdir(), 'openpencil-element-tools-composition');
const EMPTY = JSON.stringify({ version: '1.0.0', children: [] });
async function fresh(name: string): Promise<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRootChildren(doc: Record<string, unknown>): Record<string, unknown>[] {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | undefined;
return top ?? pages?.[0]?.children ?? [];
}
function findRoleInTree(
nodes: Record<string, unknown>[],
role: string,
): Record<string, unknown> | undefined {
for (const n of nodes) {
if (n.role === role) return n;
const kids = n.children as Record<string, unknown>[] | undefined;
if (kids) {
const hit = findRoleInTree(kids, role);
if (hit) return hit;
}
}
return undefined;
}
function findIdInTree(
nodes: Record<string, unknown>[],
id: string,
): Record<string, unknown> | undefined {
for (const n of nodes) {
if (n.id === id) return n;
const kids = n.children as Record<string, unknown>[] | undefined;
if (kids) {
const hit = findIdInTree(kids, id);
if (hit) return hit;
}
}
return undefined;
}
function nodeIdFrom(result: { results?: Array<{ nodeId?: string | null }> | undefined }): string {
const id = result.results?.[0]?.nodeId;
if (!id) throw new Error('tool call returned no nodeId');
return id;
}
beforeEach(async () => {
await mkdir(TMP, { recursive: true });
});
afterEach(async () => {
for (const f of ['settings.op', 'dashboard.op', 'login.op', 'profile.op', 'listing.op']) {
try {
const fp = join(TMP, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('element-tools composition — N-tool real screens', () => {
it('mobile settings: top nav + 2 sections × 3 list rows + bottom nav (9 calls)', async () => {
const fp = await fresh('settings.op');
// Top nav bar at root
const top = await handleAddTopNavBarV0({
filePath: fp,
title: 'Settings',
leading_icon: 'chevron-left',
});
const topId = nodeIdFrom(top);
// Section 1 header
const sec1 = await handleAddSectionHeaderV0({
filePath: fp,
title: 'Account',
});
const sec1Id = nodeIdFrom(sec1);
// 3 list rows under section 1
const acc1 = await handleAddListRowV0({
filePath: fp,
title: 'Profile',
leading_icon: 'user',
trailing_icon: 'chevron-right',
});
const acc2 = await handleAddListRowV0({
filePath: fp,
title: 'Privacy',
leading_icon: 'shield',
trailing_icon: 'chevron-right',
});
const acc3 = await handleAddListRowV0({
filePath: fp,
title: 'Notifications',
leading_icon: 'bell',
trailing_icon: 'chevron-right',
});
// Section 2 header
const sec2 = await handleAddSectionHeaderV0({
filePath: fp,
title: 'Support',
});
const sec2Id = nodeIdFrom(sec2);
// 3 rows under section 2
const sup1 = await handleAddListRowV0({
filePath: fp,
title: 'Help Center',
leading_icon: 'help-circle',
});
const sup2 = await handleAddListRowV0({
filePath: fp,
title: 'Contact Us',
leading_icon: 'mail',
});
const sup3 = await handleAddListRowV0({
filePath: fp,
title: 'About',
leading_icon: 'info',
});
// Bottom nav at root
const bot = await handleAddBottomNavV0({
filePath: fp,
items: [
{ title: 'Home', icon: 'home' },
{ title: 'Search', icon: 'search' },
{ title: 'Profile', icon: 'user', active: true },
],
});
// Assert all 10 calls emitted a nodeId
for (const r of [top, sec1, acc1, acc2, acc3, sec2, sup1, sup2, sup3, bot]) {
expect(r.results?.[0]?.nodeId).toBeDefined();
}
// Validate tree
const doc = await readDoc(fp);
const children = getRootChildren(doc);
// At minimum: top nav + 2 section headers + 6 list rows + bottom nav = 10 nodes
expect(children.length).toBe(10);
expect(findRoleInTree(children, 'top-nav-bar')).toBeDefined();
expect(findRoleInTree(children, 'section-header')).toBeDefined();
expect(findRoleInTree(children, 'list-row')).toBeDefined();
expect(findRoleInTree(children, 'bottom-tab-bar')).toBeDefined();
// Cross-check: all 10 ids findable
for (const r of [top, sec1, acc1, acc2, acc3, sec2, sup1, sup2, sup3, bot]) {
expect(findIdInTree(children, r.results[0].nodeId as string)).toBeDefined();
}
// Unused vars suppression
void sec1Id;
void sec2Id;
void topId;
});
it('dashboard home: nav + stat grid + section + 3 metric comparisons + chart (7 calls)', async () => {
const fp = await fresh('dashboard.op');
await handleAddTopNavBarV0({
filePath: fp,
title: 'Dashboard',
trailing_icon: 'more-vertical',
});
await handleAddStatGridV0({
filePath: fp,
items: [
{ value: '8,432', label: 'Steps' },
{ value: '512', label: 'Kcal' },
{ value: '7h', label: 'Sleep' },
],
});
await handleAddSectionHeaderV0({
filePath: fp,
title: 'KPIs',
});
await handleAddMetricComparisonV0({
filePath: fp,
label: 'Revenue',
value: '$12k',
change: '8%',
trend: 'up',
});
await handleAddMetricComparisonV0({
filePath: fp,
label: 'Orders',
value: '1,248',
change: '3%',
trend: 'up',
});
await handleAddMetricComparisonV0({
filePath: fp,
label: 'Churn',
value: '1.2%',
change: '0.5%',
trend: 'down',
});
await handleAddChartBarsV0({
filePath: fp,
values: [4, 7, 3, 9, 5, 8, 6],
});
const doc = await readDoc(fp);
const children = getRootChildren(doc);
expect(children.length).toBe(7);
expect(findRoleInTree(children, 'top-nav-bar')).toBeDefined();
expect(findRoleInTree(children, 'stat-grid')).toBeDefined();
expect(findRoleInTree(children, 'section-header')).toBeDefined();
expect(findRoleInTree(children, 'metric-comparison')).toBeDefined();
expect(findRoleInTree(children, 'chart-bars')).toBeDefined();
});
it('login form: heading + body + 2 fields + button + link (6 calls)', async () => {
const fp = await fresh('login.op');
const h = await handleAddHeadingV0({ filePath: fp, content: 'Welcome back' });
const b = await handleAddBodyTextV0({
filePath: fp,
content: 'Sign in to continue to your account.',
});
const f1 = await handleAddFormFieldV0({
filePath: fp,
label: 'Email',
placeholder: 'you@example.com',
leading_icon: 'mail',
required: true,
});
const f2 = await handleAddFormFieldV0({
filePath: fp,
label: 'Password',
leading_icon: 'lock',
trailing_icon: 'eye',
required: true,
});
const btn = await handleAddTextButtonV0({ filePath: fp, label: 'Sign in' });
const lk = await handleAddLinkV0({ filePath: fp, label: 'Forgot password?' });
for (const r of [h, b, f1, f2, btn, lk]) {
expect(r.results?.[0]?.nodeId).toBeDefined();
}
const doc = await readDoc(fp);
const children = getRootChildren(doc);
expect(children.length).toBe(6);
expect(findRoleInTree(children, 'heading')).toBeDefined();
expect(findRoleInTree(children, 'body')).toBeDefined();
expect(findRoleInTree(children, 'form-field')).toBeDefined();
expect(findRoleInTree(children, 'button')).toBeDefined();
expect(findRoleInTree(children, 'link')).toBeDefined();
});
it('profile + UGC: nav + avatar + heading + badge + 2 FAQ items + action menu (7 calls)', async () => {
const fp = await fresh('profile.op');
await handleAddTopNavBarV0({
filePath: fp,
title: 'Profile',
trailing_icon: 'more-vertical',
});
await handleAddAvatarV0({ filePath: fp, initial: 'SJ', size: 80 });
await handleAddHeadingV0({ filePath: fp, content: 'Sarah Johnson' });
await handleAddBadgeV0({ filePath: fp, label: 'PRO' });
await handleAddFaqItemV0({
filePath: fp,
question: 'Can I change my plan?',
answer: 'Go to Settings → Billing.',
expanded: true,
});
await handleAddFaqItemV0({
filePath: fp,
question: 'How do I cancel?',
});
await handleAddActionMenuV0({
filePath: fp,
items: [
{ label: 'Edit', icon: 'pencil' },
{ label: 'Share', icon: 'share' },
{ label: 'Delete', icon: 'trash', destructive: true, divider_before: true },
],
});
const doc = await readDoc(fp);
const children = getRootChildren(doc);
expect(children.length).toBe(7);
expect(findRoleInTree(children, 'faq-item')).toBeDefined();
expect(findRoleInTree(children, 'action-menu')).toBeDefined();
expect(findRoleInTree(children, 'action-menu-item-destructive')).toBeDefined();
});
it('listing: search + card row + divider + empty chart + date picker + chip input + pagination (7 calls)', async () => {
const fp = await fresh('listing.op');
await handleAddSearchBarV0({ filePath: fp, placeholder: 'Search…' });
await handleAddCardRowV0({
filePath: fp,
items: [
{ title: 'A', subtitle: '1' },
{ title: 'B', subtitle: '2' },
{ title: 'C', subtitle: '3' },
],
});
await handleAddDividerV0({ filePath: fp });
await handleAddEmptyChartV0({
filePath: fp,
title: 'No trends yet',
subtitle: 'Come back later',
icon: 'line-chart',
});
await handleAddDatePickerV0({
filePath: fp,
label: 'Filter date',
value: 'Jan 15, 2026',
clearable: true,
});
await handleAddChipInputV0({
filePath: fp,
label: 'Tags',
chips: ['design', 'mobile'],
});
await handleAddPaginationV0({ filePath: fp, total: 20, current: 3 });
const doc = await readDoc(fp);
const children = getRootChildren(doc);
expect(children.length).toBe(7);
expect(findRoleInTree(children, 'search-bar')).toBeDefined();
expect(findRoleInTree(children, 'empty-chart')).toBeDefined();
expect(findRoleInTree(children, 'date-picker')).toBeDefined();
expect(findRoleInTree(children, 'chip-input')).toBeDefined();
expect(findRoleInTree(children, 'pagination')).toBeDefined();
});
it('parent_id threading: nested insert actually lands under named parent', async () => {
const fp = await fresh('settings.op');
// Insert section header at root
const sec = await handleAddSectionHeaderV0({
filePath: fp,
title: 'Account',
});
const secId = nodeIdFrom(sec);
// Insert a list row with parent_id = section id
const row = await handleAddListRowV0({
filePath: fp,
title: 'Profile',
parent_id: secId,
});
const rowId = nodeIdFrom(row);
// Verify: rowId must be nested UNDER secId in the tree
const doc = await readDoc(fp);
const children = getRootChildren(doc);
// Find the section at root and confirm the row is one of its children
const section = findIdInTree(children, secId)!;
expect(section).toBeDefined();
const sectionKids = section.children as Record<string, unknown>[] | undefined;
expect(sectionKids).toBeDefined();
expect(findIdInTree(sectionKids ?? [], rowId)).toBeDefined();
// And the row should NOT appear at root (if it did, parent_id threading failed silently)
const rootLevel = children.filter((n) => n.id === rowId);
expect(rootLevel.length).toBe(0);
});
});