Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/electron/e2e/heatmap-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { chromium } from 'playwright-core';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';

const directory = fileURLToPath(new URL('.', import.meta.url));
const output = resolve(directory, 'artifacts/heatmap');
const url = process.env.HEATMAP_URL ?? `http://127.0.0.1:5174/@fs/${resolve(directory, 'heatmap.html').replaceAll('\\', '/')}`;
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ channel: process.env.HEATMAP_BROWSER ?? 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
const started = performance.now();
await page.goto(url);
await page.getByTestId('heatmap-tile-AAPL').waitFor();
assert.equal(await page.locator('[data-testid^="heatmap-tile-"]').count(), 6);
const relativeArea = () => page.evaluate(() => {
const apple = document.querySelector('[data-testid="heatmap-tile-AAPL"]').getBoundingClientRect();
const nvidia = document.querySelector('[data-testid="heatmap-tile-NVDA"]').getBoundingClientRect();
return apple.width * apple.height / (nvidia.width * nvidia.height);
});
assert.ok(Math.abs(await relativeArea() - 1) < 0.04, 'Equal mode gives equal tile area');
const renderMs = performance.now() - started;
await page.screenshot({ path: resolve(output, 'heatmap-equal.png'), fullPage: true });

await page.getByLabel('Tile size').selectOption('weight');
assert.ok(Math.abs(await relativeArea() - 1.2) < 0.08, 'Weighted area follows snapshot values');
assert.ok((await page.getByTestId('heatmap-tile-AAPL').getAttribute('title')).includes('30%'));
await page.screenshot({ path: resolve(output, 'heatmap-weighted.png'), fullPage: true });
await page.getByRole('button', { name: 'Table', exact: true }).click();
assert.equal(await page.getByTestId('heatmap-table').locator('tbody tr').count(), 6);
await page.getByTestId('heatmap-table').getByRole('button', { name: /Apple Inc/ }).click();
assert.ok((await page.getByTestId('heatmap-detail').textContent()).includes('30%'));
await page.getByRole('button', { name: 'Refresh heatmap', exact: true }).click();
await page.getByTestId('heatmap-detail').getByText(/Apple Inc/).waitFor();
await page.screenshot({ path: resolve(output, 'heatmap-table.png'), fullPage: true });

await page.getByRole('button', { name: 'Heatmap', exact: true }).click();
await page.getByTestId('heatmap-tile-NVDA').focus();
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Back to heatmap', exact: true }).waitFor();
assert.ok((await page.textContent('body')).includes('NVDA.US'));
await page.screenshot({ path: resolve(output, 'heatmap-research.png'), fullPage: true });

await page.getByRole('button', { name: 'Back to heatmap', exact: true }).click();
await page.getByTestId('heatmap-tile-AAPL').click();
await page.getByRole('button', { name: 'Back to heatmap', exact: true }).waitFor();
assert.ok((await page.textContent('body')).includes('AAPL.US'));
await page.getByRole('button', { name: 'Back to heatmap', exact: true }).click();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId('heatmap-tile-AAPL').waitFor();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
assert.equal(overflow, false, 'Mobile page must not overflow horizontally');
await page.screenshot({ path: resolve(output, 'heatmap-mobile.png'), fullPage: true });

await page.setViewportSize({ width: 1280, height: 900 });
const benchmarkStarted = performance.now();
await page.goto(`${url}?count=100`);
await page.getByTestId('heatmap-tile-FIX99').waitFor();
const fixture100NavigationToTilesMs = Math.round(performance.now() - benchmarkStarted);
const rectangles = await page.locator('[data-testid^="heatmap-tile-"]').evaluateAll((tiles) => tiles.map((tile) => {
const box = tile.getBoundingClientRect();
const map = tile.parentElement.getBoundingClientRect();
return { width: box.width, height: box.height, inside: box.x >= map.x - 1 && box.y >= map.y - 1 && box.right <= map.right + 1 && box.bottom <= map.bottom + 1 };
}));
assert.equal(rectangles.length, 100);
assert.ok(rectangles.every((box) => box.width > 0 && box.height > 0 && box.inside));
assert.deepEqual(errors, [], 'No browser runtime errors');
const report = {
environment: `Node ${process.version} / ${process.platform} / ${await browser.version()}`,
data: 'Fixture prices and positions; six real canonical catalog securities. No live provider/model.',
tests: ['six real catalog securities', 'equal and weighted modes', 'correct ratio percentages', 'table from same snapshot', 'refresh preserves selection', 'keyboard Enter to actual ResearchPanel', 'mouse click to actual ResearchPanel', '390px responsive layout', '100 fixture DOM tiles with positive sizes inside map', 'no browser runtime errors'],
pass: 10, fail: 0, coldNavigationToFirstTileMs: Math.round(renderMs), fixture100NavigationToTilesMs,
};
await writeFile(resolve(output, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
} finally {
await browser.close();
}
15 changes: 15 additions & 0 deletions apps/electron/e2e/heatmap.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Folio heatmap component verification</title></head>
<body>
<div id="root"></div>
<script type="module">
import RefreshRuntime from '/@react-refresh';
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="./heatmap.tsx"></script>
</body>
</html>
84 changes: 84 additions & 0 deletions apps/electron/e2e/heatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider, createStore, useAtomValue, useSetAtom } from 'jotai';
import { I18nextProvider } from 'react-i18next';
import { createSyncI18n } from '@finagent/i18n';
import { DEFAULT_INSTRUMENT_CATALOG, type HeatmapSnapshot, type HeatmapSourceSnapshot } from '@finagent/core';
import { fallbackClient, FinagentClientProvider } from '../../../packages/ui/src/client';
import { activeSymbolAtom, navSectionAtom, selectedPositionAtom } from '../../../packages/ui/src/atoms';
import { researchOriginAtom } from '../../../packages/ui/src/atoms/discoverAtoms';
import { HeatmapPanel } from '../../../packages/ui/src/components/heatmap/HeatmapPanel';
import { ResearchPanel } from '../../../packages/ui/src/components/research/ResearchPanel';
import '../src/renderer/styles/index.css';

// These fixtures use real catalog identities with deliberately synthetic prices/holdings.
const benchmark = new URLSearchParams(location.search).get('count') === '100';
const instruments = benchmark ? Array.from({ length: 100 }, (_, index) => ({
...DEFAULT_INSTRUMENT_CATALOG[0]!,
instrumentId: `XNAS:FIX${index}`, symbol: `FIX${index}`, name: `Fixture Security ${index}`,
providerAliases: [{ providerId: 'longbridge', symbol: `FIX${index}.US` }],
})) : DEFAULT_INSTRUMENT_CATALOG.slice(0, 6);
const source: HeatmapSourceSnapshot = {
schemaVersion: 1, id: 'verification-portfolio', revision: 'fixture-1', kind: 'portfolio',
capturedAt: Date.UTC(2026, 9, 16, 20), baseCurrency: 'USD', valuationCurrency: 'USD',
entries: instruments.map((instrument) => ({ symbol: instrument.instrumentId })),
};
const snapshot: HeatmapSnapshot = {
schemaVersion: 1, source, fetchedAt: source.capturedAt, weightedAvailable: true, unresolved: [],
tiles: instruments.map((instrument, index) => ({
instrument,
workspaceSymbol: instrument.providerAliases.find((alias) => alias.providerId === 'longbridge')!.symbol,
industry: benchmark
? { name: `Fixture industry ${index % 5}`, classification: 'SIC', code: `357${index % 5}` }
: index < 3 ? { name: 'Fixture technology', classification: 'SIC', code: '3571' } : undefined,
changePercent: [1.2, -2.1, 4.2, -0.5, 0, null][index % 6]!,
state: index === 5 ? 'unknown' : index === 4 ? 'stale' : 'current',
session: index === 4 ? 'Closed' : 'Open',
provenance: { providerId: 'fixture', providerName: 'Fixture provider', instrumentId: instrument.instrumentId, fetchedAt: source.capturedAt, marketTime: source.capturedAt, stale: index === 4, delayed: index === 4 },
valueBase: benchmark ? 100 : [300, 100, 250, 150, 100, 100][index]!,
weight: benchmark ? 0.01 : [0.3, 0.1, 0.25, 0.15, 0.1, 0.1][index]!,
})),
};

const store = createStore();
store.set(navSectionAtom, 'portfolio');
const client = {
...fallbackClient,
market: {
...fallbackClient.market,
getHeatmap: async (input: { source: HeatmapSourceSnapshot }) => ({ ok: true as const, data: { ...snapshot, source: input.source } }),
cancelHeatmap: async () => ({ ok: true as const, data: undefined }),
getQuote: async (symbol: string) => ({ ok: true as const, data: { symbol, lastPrice: 100, change: 1, changePercent: 1, timestamp: source.capturedAt / 1000, volume: 1000, high: 101, low: 99, open: 99, prevClose: 99 } }),
getStaticInfo: async (symbol: string) => ({ ok: true as const, data: { symbol, name: DEFAULT_INSTRUMENT_CATALOG.find((item) => item.providerAliases.some((alias) => alias.symbol === symbol))?.name ?? symbol } }),
getKline: async () => ({ ok: true as const, data: [] }),
},
};

function Verification() {
const nav = useAtomValue(navSectionAtom);
const setNav = useSetAtom(navSectionAtom);
const setActiveSymbol = useSetAtom(activeSymbolAtom);
const setSelectedPosition = useSetAtom(selectedPositionAtom);
const setOrigin = useSetAtom(researchOriginAtom);
return <main className="min-h-screen bg-background p-4 text-foreground sm:p-6">
<header className="mb-5 flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
<strong className="text-[17px]">Folio</strong>
<span className="text-[11px] text-foreground/58">Fixture data · real catalog instruments · no live provider or model</span>
</header>
{nav === 'research' ? <>
<button type="button" className="mb-3 text-[12px] text-[var(--mac-blue)]" onClick={() => setNav('portfolio')}>Back to heatmap</button>
<ResearchPanel />
</> : <HeatmapPanel source={source}
onSelect={(tile) => { setActiveSymbol(tile.workspaceSymbol); setSelectedPosition(tile.workspaceSymbol); }}
onResearch={(tile) => { setOrigin({ from: 'portfolio', label: tile.workspaceSymbol }); setNav('research'); }}
/>}
</main>;
}

createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<I18nextProvider i18n={createSyncI18n({ locale: 'en-US' })}>
<FinagentClientProvider client={client}><Verification /></FinagentClientProvider>
</I18nextProvider>
</Provider>,
);
7 changes: 7 additions & 0 deletions apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ ipcMain.handle('market:getQuote', async (_event, symbol: unknown) =>
toIpcResult(() => agentKernelHost.getQuote(symbol))
);

ipcMain.handle('market:getHeatmap', async (event, input: unknown) =>
toIpcResult(() => agentKernelHost.getHeatmap(input, event.sender.id))
);
ipcMain.handle('market:cancelHeatmap', async (event, requestId: unknown) =>
toIpcResult(() => agentKernelHost.cancelHeatmap(requestId, event.sender.id))
);

ipcMain.handle('market:getKline', async (_event, request: unknown) =>
toIpcResult(() => agentKernelHost.getKline(request))
);
Expand Down
46 changes: 46 additions & 0 deletions apps/electron/src/main/kernelHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ class FakeMarketDataService {
return [{ input }];
}

async getQuoteResult(symbol: string, _signal?: AbortSignal): Promise<{
data: import('@finagent/core').Quote;
provenance: import('@finagent/core').ProviderProvenance;
}> {
return {
data: { symbol, lastPrice: 101, change: 1, changePercent: 1, timestamp: Date.now() / 1000, prevClose: 100, high: 101, low: 100, open: 100, volume: 10 },
provenance: { providerId: 'fixture', providerName: 'Fixture', instrumentId: 'XNAS:AAPL', fetchedAt: Date.now(), marketTime: Date.now(), stale: false },
};
}

async getStaticInfo(symbol: string) { return { symbol, name: 'Apple Inc.' }; }
async getMarketStatus() { return [{ market: 'US', status: 'Open' }]; }

async getPortfolio() {
return {
totalAssets: 1000,
Expand Down Expand Up @@ -111,6 +124,8 @@ const fakeRuns = {
replayStream: (_runId: string, _lastSequence: number) => ({ recoverable: false, events: [], atEnd: true }),
};

const { HeatmapService, parseHeatmapSource } = await import('../../../../packages/shared/src/heatmap/service.ts');

class FakeAgentKernel {
sessions = fakeSessions;
runs = fakeRuns;
Expand Down Expand Up @@ -146,6 +161,8 @@ const noopStore = class {
};

mock.module('@finagent/shared', () => ({
HeatmapService,
parseHeatmapSource,
AgentKernel: FakeAgentKernel,
MarketDataService: class extends FakeMarketDataService {
constructor(options?: { fetchers?: unknown }) {
Expand Down Expand Up @@ -611,6 +628,35 @@ describe('AgentKernelHost', () => {
host.dispose();
});

it('validates heatmap IPC snapshots and binds canonical listing identity', async () => {
const host = new AgentKernelHost();
const source = { schemaVersion: 1, id: 'watch', revision: 'v1', kind: 'watchlist', capturedAt: Date.now(), entries: [{ symbol: 'AAPL.US' }] };
const result = await host.getHeatmap({ requestId: 'test-1', source }, 1);
expect(result.tiles[0]?.instrument.instrumentId).toBe('XNAS:AAPL');
expect(result.tiles[0]?.workspaceSymbol).toBe('AAPL.US');
expect(result.source.revision).toBe('v1');
await expect(host.getHeatmap({ requestId: 'test-2', source: { ...source, schemaVersion: 2 } }, 1)).rejects.toThrow('Invalid heatmap');
host.dispose();
});

it('isolates heatmap cancellation by renderer owner', async () => {
const host = new AgentKernelHost();
let providerSignal: AbortSignal | undefined;
lastMarketData!.getQuoteResult = (_symbol, signal) => new Promise((_resolve, reject) => {
providerSignal = signal;
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
});
const source = { schemaVersion: 1, id: 'watch', revision: 'v1', kind: 'watchlist', capturedAt: Date.now(), entries: [{ symbol: 'AAPL.US' }] };
const pending = host.getHeatmap({ requestId: 'same-id', source }, 10);
await waitFor(() => providerSignal !== undefined);
host.cancelHeatmap('same-id', 11);
expect(providerSignal?.aborted).toBe(false);
host.cancelHeatmap('same-id', 10);
await expect(pending).rejects.toThrow('aborted');
expect(providerSignal?.aborted).toBe(true);
host.dispose();
});

it('passes the fetched portfolio scope and timestamp to the automation runner', async () => {
const host = new AgentKernelHost();
const rule: AutomationRule = {
Expand Down
28 changes: 28 additions & 0 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ import {
JsonFileStore,
LocalResearchSynthesizer,
MarketDataService,
HeatmapService,
parseHeatmapSource,
MassiveFinancialDataProvider,
YahooChartFinancialDataProvider,
parseImpactJson,
Expand Down Expand Up @@ -273,6 +275,7 @@ interface PendingEvalRun {
*/
export class AgentKernelHost {
private readonly marketData: MarketDataService;
private readonly heatmapRequests = new Map<string, AbortController>();
private readonly kernel: AgentKernel;
private readonly credentials: CredentialStore;
private readonly skillHub: SkillHub;
Expand Down Expand Up @@ -617,6 +620,29 @@ export class AgentKernelHost {
return this.marketData.getQuote(requireString(symbol, 'symbol').toUpperCase());
}

async getHeatmap(input: unknown, owner: number) {
const request = requireObject(input);
const requestId = requireString(request.requestId, 'requestId');
if (requestId.length > 128) throw new Error('Invalid heatmap request id.');
const source = parseHeatmapSource(request.source);
const key = `${owner}:${requestId}`;
this.heatmapRequests.get(key)?.abort();
const controller = new AbortController();
this.heatmapRequests.set(key, controller);
try {
return await new HeatmapService({
marketData: this.marketData,
resolve: (query) => this.instrumentResolver.resolve(query),
}).load(source, controller.signal);
} finally {
if (this.heatmapRequests.get(key) === controller) this.heatmapRequests.delete(key);
}
}

cancelHeatmap(requestId: unknown, owner: number) {
this.heatmapRequests.get(`${owner}:${requireString(requestId, 'requestId')}`)?.abort();
}

getKline(input: unknown) {
const request = requireObject(input);
const payload: KlineRequest = {
Expand Down Expand Up @@ -2649,6 +2675,8 @@ export class AgentKernelHost {
}

async dispose() {
for (const controller of this.heatmapRequests.values()) controller.abort();
this.heatmapRequests.clear();
this.alertEngine.stop();
this.unsubscribe?.();
this.unsubscribe = null;
Expand Down
2 changes: 2 additions & 0 deletions apps/electron/src/preload/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ var electronAPI = {
getTools: () => import_electron.ipcRenderer.invoke("agent:getTools")
},
market: {
getHeatmap: (input) => import_electron.ipcRenderer.invoke("market:getHeatmap", input),
cancelHeatmap: (requestId) => import_electron.ipcRenderer.invoke("market:cancelHeatmap", requestId),
getQuote: (symbol) => import_electron.ipcRenderer.invoke("market:getQuote", symbol),
getKline: (request) => import_electron.ipcRenderer.invoke("market:getKline", request),
getPortfolio: () => import_electron.ipcRenderer.invoke("market:getPortfolio"),
Expand Down
4 changes: 4 additions & 0 deletions apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface ElectronAPI {
getTools: () => Promise<unknown>;
};
market: {
getHeatmap: (input: unknown) => Promise<unknown>;
cancelHeatmap: (requestId: string) => Promise<unknown>;
getQuote: (symbol: string) => Promise<unknown>;
getKline: (request: { symbol: string; period?: string; limit?: number }) => Promise<unknown>;
getPortfolio: () => Promise<unknown>;
Expand Down Expand Up @@ -231,6 +233,8 @@ const electronAPI: ElectronAPI = {
getTools: () => ipcRenderer.invoke('agent:getTools'),
},
market: {
getHeatmap: (input) => ipcRenderer.invoke('market:getHeatmap', input),
cancelHeatmap: (requestId) => ipcRenderer.invoke('market:cancelHeatmap', requestId),
getQuote: (symbol: string) => ipcRenderer.invoke('market:getQuote', symbol),
getKline: (request: { symbol: string; period?: string; limit?: number }) =>
ipcRenderer.invoke('market:getKline', request),
Expand Down
2 changes: 2 additions & 0 deletions apps/electron/src/renderer/finagentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function createElectronClient(): FinagentClient {
getTools: () => ipcResult(window.electronAPI.agent.getTools()),
},
market: {
getHeatmap: (input) => ipcResult(window.electronAPI.market.getHeatmap(input)),
cancelHeatmap: (requestId) => ipcResult(window.electronAPI.market.cancelHeatmap(requestId)),
getQuote: (symbol) => ipcResult(window.electronAPI.market.getQuote(symbol)),
getKline: (request) => ipcResult(window.electronAPI.market.getKline(request)),
getPortfolio: () => ipcResult(window.electronAPI.market.getPortfolio()),
Expand Down
Loading