Files
world/e2e/context-menu.spec.ts
zeekayandHanzo Dev a4ebbeeee7 feat(world): per-component right-click menus + world MCP data tools in the analyst loop
Context menus (panel-menu.ts): extend the single installed contextmenu
listener into a per-context system. Layers, most-specific first: text entry
→ native menu; component items via a data-ctx-* convention (news → Open/Copy
link + Copy headline; market/FX/commodity/yield rows → Copy value/symbol);
the map surface → Copy coordinates / Fly here / 2D-3D toggle through a narrow
MapContextPort (registered by MapContainer); then the panel baseline (Hide,
Move to top, five Size presets, Reset layout). A stationary right-click on the
map opens the menu; a rotate-drag still bails so maplibre keeps the gesture.
Size presets reuse Panel's setSpanClass/savePanelSpan; Move to top routes
through App.movePanelInGrid via a new panel-move-request event — one way each.
Panels only annotate their DOM (quoteRow, MarketPanel, NewsPanel,
CustomFeedPanel); DeckGLMap gains a read-only screenToLngLat.

Analyst data tools (handlers_analyst.go + mcp/): bind the 7 world MCP tools as
DATA tools in the analyst loop. mcp.CallTool executes them IN-PROCESS via the
same dispatcher tools/call uses (one path, no self-HTTP); mcp.ToolSpecs feeds a
derived tool contract into the system prompt. The handler runs an agentic loop
(≤3 tool rounds) — the model requests tools in a "tools" array, results are fed
back, then it answers citing live data. Tool traces ride the response and render
as collapsed "🔧 tool(...)" lines in the chat (AnalystChat). App-commands stay
auth-gated (unchanged); data tools dispatch anonymously.

Gates: go build+test (incl. new agentic-loop + parse/contract tests), tsc,
vite build, playwright (news/map menus + stubbed analyst tool round-trip) — all
green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 02:58:59 -07:00

175 lines
7.1 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Context-menu + analyst-data-tool e2e.
*
* 1. Right-click a news-shaped item (the exact data-ctx-* convention NewsPanel
* emits) → the custom menu shows Open link / Copy link / Copy headline plus
* the panel baseline; Copy actually writes to the clipboard.
* 2. Right-click the live map → the map menu shows Copy coordinates / Fly here /
* the 2D-3D toggle (via the MapContainer capability port).
* 3. A stubbed analyst response carrying a data-tool trace renders the collapsed
* "🔧 world_brief(...)" line before the grounded reply.
*
* No real inference or feeds: the analyst/models routes are stubbed and the news
* item is injected into a live panel so the menu code runs against production DOM.
*/
const SCREENS = 'e2e/screens';
async function stubWorldApi(page: Page, analystBody?: unknown): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
analystBody ?? { reply: '', actions: [], model: 'zen5', tokens: 0, traces: [] },
),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [{ id: 'zen5', label: 'Zen 5', group: 'Zen' }],
default: 'zen5',
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
}
// Read the current custom menu's item labels (buttons only, not separators/labels).
async function menuItems(page: Page): Promise<string[]> {
return page.$$eval('#panelContextMenu .panel-context-menu-item', (els) =>
els.map((e) => (e.textContent || '').trim()),
);
}
test.describe('right-click context menus', () => {
test('news item → Open link / Copy link / Copy headline (+ clipboard)', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await stubWorldApi(page);
await appReady(page);
// Add a faithful news item (exactly the data-ctx-* attributes NewsPanel
// emits) inside its own panel appended to the grid. A synthetic panel is used
// so a live panel's periodic re-render can't wipe the item mid-test, and so it
// is not under the map canvas — the menu code path is identical either way.
await page.evaluate(() => {
const grid = document.querySelector('#panelsGrid')!;
const panel = document.createElement('div');
panel.className = 'panel';
panel.dataset.panel = 'e2e-test-panel';
panel.innerHTML =
'<div class="panel-content"><div class="item" id="e2e-news-item" ' +
'data-ctx-url="https://example.com/story" data-ctx-headline="Breaking test headline" ' +
'style="padding:12px;min-height:24px;">Breaking test headline</div></div>';
grid.appendChild(panel);
});
await page.locator('#e2e-news-item').scrollIntoViewIfNeeded();
await page.locator('#e2e-news-item').click({ button: 'right' });
await expect(page.locator('#panelContextMenu')).toBeVisible();
const items = await menuItems(page);
expect(items).toEqual(expect.arrayContaining(['Open link', 'Copy link', 'Copy headline']));
// Baseline panel actions still ride below the component items.
expect(items).toEqual(expect.arrayContaining(['Hide panel', 'Reset layout', 'Full']));
await page.screenshot({ path: `${SCREENS}/ctxmenu-news.png` });
// Copy headline actually writes to the clipboard.
await page.locator('#panelContextMenu .panel-context-menu-item', { hasText: 'Copy headline' }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe('Breaking test headline');
// Menu dismisses after an action.
await expect(page.locator('#panelContextMenu')).toHaveCount(0);
});
test('map → Copy coordinates / Fly here / 2D-3D toggle', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
// The map is a first-class grid citizen in the full app; wait for its canvas.
await page.waitForSelector('.map-container canvas', { timeout: 60_000 });
// A stationary right-click on the map surface opens the map menu (a rotate
// drag would instead keep the maplibre gesture — not exercised here).
await expect
.poll(
async () => {
await page.locator('.map-container canvas').first().click({ button: 'right', position: { x: 300, y: 180 } });
if (!(await page.locator('#panelContextMenu').count())) return [];
return menuItems(page);
},
{ timeout: 30_000 },
)
.toEqual(expect.arrayContaining(['Copy coordinates', 'Fly here']));
const items = await menuItems(page);
// The 2D-3D toggle is present (label depends on current projection).
expect(items.some((l) => /Switch to (2D map|3D globe)/.test(l))).toBe(true);
await page.screenshot({ path: `${SCREENS}/ctxmenu-map.png` });
});
});
test.describe('analyst data-tool traces', () => {
test('a stubbed tool round-trip renders the 🔧 trace + grounded reply', async ({ page }) => {
await signIn(page);
await stubWorldApi(page, {
reply: 'Composite instability is steady; Sudan and Ukraine lead the movers.',
actions: [],
model: 'zen5',
tokens: 128,
traces: [
{
label: 'world_brief({"n":5})',
ok: true,
result: '{"asOf":"2026-07-09T00:00:00Z","kind":"country","top":[{"iso":"SD","instability":0.82}]}',
},
],
});
await appReady(page);
await page.click('.ai-dock-fab');
const composer = page.locator('.ai-dock-body .ai-analyst-input');
await expect(composer).toBeVisible();
await composer.fill('What is the state of global instability?');
await page.click('.ai-dock-body .ai-analyst-send');
// The collapsed tool trace renders before the reply that cites it.
const trace = page.locator('.ai-dock-body .ai-analyst-tool .ai-analyst-tool-summary');
await expect(trace).toBeVisible();
await expect(trace).toContainText('🔧 world_brief(');
// Expanding it reveals the raw result body.
await trace.click();
await expect(page.locator('.ai-dock-body .ai-analyst-tool-result')).toContainText('instability');
// …and the grounded prose reply is shown.
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('instability is steady');
await page.screenshot({ path: `${SCREENS}/analyst-tool-trace.png` });
});
});