mirror of
https://github.com/hanzo-apps/world.git
synced 2026-10-30 14:53:09 +00:00
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]>
175 lines
7.1 KiB
TypeScript
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` });
|
|
});
|
|
});
|