test(pen-mcp): support-chat composition scenario covering #63-#67 + v1
Extends element-tools-composition.test.ts with a 7th real-screen
scenario that exercises every tool added after the 62-tool mark:
- add_top_nav_bar_v0 (existing anchor)
- add_chat_bubble_v0 × 2 (left from-others + right from-self)
- add_attachment_row_v0 (file on the self-message path)
- add_upload_dropzone_v0 (drop area for screenshots)
- add_chip_input_v0 (conversation tags)
- add_action_menu_v0 (floating menu, open state)
- add_modal_shell_v1 (theme="dark" confirm dialog)
- add_otp_input_v0 (phone verification step)
8 tool calls, chained through the full MCP handler pipeline
(ensureParentExists → builder → assignIdsRecursively →
batch_design insert with rollback-on-failure → post-insert
landing check → save → re-read from disk). Asserts:
- every call emits a nodeId (no silent no-ops)
- final doc has exactly 9 root children
- every tool's role marker survives round-trip save/load
- modal v1 theme=dark → card fill #1E293B (not v0's #FFFFFF)
- chat right-side bubble surface → #2563EB accent
- OTP focused slot stroke → #2563EB accent
This is end-to-end at the DATA layer, not the visual layer.
Still not covered by any test:
- Skia rendering (needs debug_screenshot against a live
canvas)
- Real LLM tool routing (needs ab-corpus harness with live
API keys — route to 方舟 CP works but has not run since
the routing fix)
Document-layer coverage is enough to rule out handler-pipeline
bugs (bad parent_id threading / cache-stale doc-state / silent
rollback swallowing); visual regressions require the next gate.
This commit is contained in:
parent
06362a4040
commit
6d4bdb88da
|
|
@ -26,6 +26,11 @@ 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';
|
||||
import { handleAddModalShellV1 } from '../tools/add-modal-shell-v1';
|
||||
import { handleAddUploadDropzoneV0 } from '../tools/add-upload-dropzone-v0';
|
||||
import { handleAddOtpInputV0 } from '../tools/add-otp-input-v0';
|
||||
import { handleAddAttachmentRowV0 } from '../tools/add-attachment-row-v0';
|
||||
import { handleAddChatBubbleV0 } from '../tools/add-chat-bubble-v0';
|
||||
|
||||
/**
|
||||
* End-to-end composition tests — verifies that N element-tool calls
|
||||
|
|
@ -108,7 +113,14 @@ beforeEach(async () => {
|
|||
await mkdir(TMP, { recursive: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const f of ['settings.op', 'dashboard.op', 'login.op', 'profile.op', 'listing.op']) {
|
||||
for (const f of [
|
||||
'settings.op',
|
||||
'dashboard.op',
|
||||
'login.op',
|
||||
'profile.op',
|
||||
'listing.op',
|
||||
'support-chat.op',
|
||||
]) {
|
||||
try {
|
||||
const fp = join(TMP, f);
|
||||
invalidateCache(fp);
|
||||
|
|
@ -427,4 +439,132 @@ describe('element-tools composition — N-tool real screens', () => {
|
|||
const rootLevel = children.filter((n) => n.id === rowId);
|
||||
expect(rootLevel.length).toBe(0);
|
||||
});
|
||||
|
||||
it('support chat: exercises every tool added after the 62-mark (#63-#67 + v1)', async () => {
|
||||
const fp = await fresh('support-chat.op');
|
||||
|
||||
// Mimics a realistic customer-support screen: header + two
|
||||
// chat bubbles (from-others then from-self) + one attachment
|
||||
// on the self message + an upload dropzone + chip input for
|
||||
// tagging + an open action menu + a dark-theme confirm modal
|
||||
// floating + an OTP input (phone verification step). Purpose
|
||||
// isn't "this is a real product screen", it's "every new tool
|
||||
// composes into the same document without throwing, with
|
||||
// expected roles + finite parent_id threading".
|
||||
|
||||
// Top nav (existing 62-family)
|
||||
await handleAddTopNavBarV0({
|
||||
filePath: fp,
|
||||
title: 'Support',
|
||||
leading_icon: 'chevron-left',
|
||||
});
|
||||
|
||||
// 2 chat bubbles
|
||||
const leftBubble = await handleAddChatBubbleV0({
|
||||
filePath: fp,
|
||||
message: 'Hi! How can I help you today?',
|
||||
side: 'left',
|
||||
author: 'Support Agent',
|
||||
timestamp: 'Just now',
|
||||
});
|
||||
const rightBubble = await handleAddChatBubbleV0({
|
||||
filePath: fp,
|
||||
message: "My order hasn't arrived and I can't find the tracking number.",
|
||||
side: 'right',
|
||||
timestamp: '2m',
|
||||
});
|
||||
|
||||
// Attachment riding on the self message (compose pattern)
|
||||
const attachment = await handleAddAttachmentRowV0({
|
||||
filePath: fp,
|
||||
filename: 'order-confirmation.pdf',
|
||||
size: '340 KB',
|
||||
icon: 'file-text',
|
||||
});
|
||||
|
||||
// Upload dropzone for additional evidence
|
||||
const dropzone = await handleAddUploadDropzoneV0({
|
||||
filePath: fp,
|
||||
title: 'Drop screenshots here',
|
||||
subtitle: 'PNG or JPG, max 5 MB',
|
||||
icon: 'upload-cloud',
|
||||
});
|
||||
|
||||
// Chip input for conversation tags
|
||||
const chips = await handleAddChipInputV0({
|
||||
filePath: fp,
|
||||
label: 'Tags',
|
||||
chips: ['billing', 'shipping'],
|
||||
placeholder: 'Add tag…',
|
||||
});
|
||||
|
||||
// Action menu (would be floating from a ⋯ button)
|
||||
const menu = await handleAddActionMenuV0({
|
||||
filePath: fp,
|
||||
items: [
|
||||
{ label: 'Mark resolved', icon: 'check' },
|
||||
{ label: 'Escalate', icon: 'arrow-up' },
|
||||
{ label: 'Archive', icon: 'archive', divider_before: true },
|
||||
],
|
||||
});
|
||||
|
||||
// Dark-theme confirm modal (v1 tool)
|
||||
const modal = await handleAddModalShellV1({
|
||||
filePath: fp,
|
||||
title: 'Escalate to supervisor?',
|
||||
subtitle: 'The customer will receive an email update.',
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
// OTP input (phone verification step)
|
||||
const otp = await handleAddOtpInputV0({
|
||||
filePath: fp,
|
||||
length: 6,
|
||||
focused_index: 0,
|
||||
});
|
||||
|
||||
// Every call must have produced a nodeId
|
||||
for (const r of [leftBubble, rightBubble, attachment, dropzone, chips, menu, modal, otp]) {
|
||||
expect(r.results?.[0]?.nodeId).toBeDefined();
|
||||
}
|
||||
|
||||
// Validate the doc has all expected roles
|
||||
const doc = await readDoc(fp);
|
||||
const children = getRootChildren(doc);
|
||||
// 1 top_nav + 2 chat bubbles + 1 attachment + 1 dropzone + 1 chip input
|
||||
// + 1 action menu + 1 modal + 1 otp = 9
|
||||
expect(children.length).toBe(9);
|
||||
|
||||
expect(findRoleInTree(children, 'top-nav-bar')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'chat-bubble-left')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'chat-bubble-right')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'attachment-row')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'upload-dropzone')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'chip-input')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'action-menu')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'modal-scrim')).toBeDefined();
|
||||
expect(findRoleInTree(children, 'otp-input')).toBeDefined();
|
||||
|
||||
// Dark modal: surface fill must be dark (not light #FFFFFF)
|
||||
const modalCard = findRoleInTree(children, 'modal-shell-card')!;
|
||||
const modalFill = (modalCard.fill as Array<{ color: string }>)[0].color;
|
||||
expect(modalFill, 'v1 theme=dark should emit dark surface').toBe('#1E293B');
|
||||
|
||||
// Self bubble: accent fill (#2563EB default)
|
||||
const rightBubbleSurface = findRoleInTree(children, 'chat-bubble-surface');
|
||||
expect(rightBubbleSurface).toBeDefined();
|
||||
// The LEFT bubble surface is also role chat-bubble-surface, so search
|
||||
// for the one under chat-bubble-right specifically.
|
||||
const selfBubbleRoot = findRoleInTree(children, 'chat-bubble-right')!;
|
||||
const selfSurface = findRoleInTree(
|
||||
[selfBubbleRoot] as Record<string, unknown>[],
|
||||
'chat-bubble-surface',
|
||||
)!;
|
||||
expect((selfSurface.fill as Array<{ color: string }>)[0].color).toBe('#2563EB');
|
||||
|
||||
// OTP focused slot: accent border
|
||||
const otpFocused = findRoleInTree(children, 'otp-slot-focused')!;
|
||||
const otpStroke = otpFocused.stroke as { fill: Array<{ color: string }> };
|
||||
expect(otpStroke.fill[0].color).toBe('#2563EB');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue