const { chromium } = require('playwright'); const WORKSPACE_STORAGE_KEY = 'aiforecast_workspace'; async function dispatchControlValue(page, selector, value) { await page.locator(selector).evaluate((element, nextValue) => { element.value = nextValue; element.dispatchEvent(new Event('change', { bubbles: true })); }, value); } async function waitForPaneLabel(page, selector, expectedText) { await page.waitForFunction( ({ targetSelector, text }) => { const element = document.querySelector(targetSelector); return Boolean(element && String(element.textContent || '').includes(text)); }, { targetSelector: selector, text: expectedText }, ); } async function assertPaneCount(page, expectedCount) { await page.waitForFunction( (count) => document.querySelectorAll('.chart-pane').length === count, expectedCount, ); } async function switchLayout(page, preset) { await page.evaluate((nextPreset) => { const menuButton = document.querySelector('#layoutMenuBtn'); const layoutButton = document.querySelector(`button[data-layout="${nextPreset}"]`); menuButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); layoutButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }, preset); await page.waitForSelector(`.workspace-grid.layout-${preset}`); await assertPaneCount(page, preset); } async function assertModelAvailabilityMatchesHealth(page) { const health = await page.evaluate(async () => { const response = await fetch('/api/health'); return response.json(); }); const modelButtonIds = { kronos: '#kronosToggle', timesfm: '#timesfmToggle', chronos: '#chronosToggle', }; for (const [modelKey, selector] of Object.entries(modelButtonIds)) { const expectedAvailable = Boolean(health?.[modelKey]?.available); await page.waitForFunction( ({ targetSelector, available }) => { const button = document.querySelector(targetSelector); if (!(button instanceof HTMLButtonElement)) { return false; } return button.dataset.available === String(available); }, { targetSelector: selector, available: expectedAvailable }, ); const isDisabled = await page.locator(selector).isDisabled(); if (expectedAvailable && isDisabled) { throw new Error(`${modelKey} should be enabled when /api/health reports available=true`); } if (!expectedAvailable && !isDisabled) { throw new Error(`${modelKey} should be disabled when /api/health reports available=false`); } } } async function assertFocusMode(page, paneSelector) { await page.locator(paneSelector).dblclick(); await page.waitForFunction( (targetSelector) => { const grid = document.querySelector('#workspaceGrid'); const pane = document.querySelector(targetSelector); return Boolean( grid?.classList.contains('pane-focus-mode') && pane?.classList.contains('is-maximized'), ); }, paneSelector, ); await page.locator(paneSelector).dblclick(); await page.waitForFunction( () => !document.querySelector('#workspaceGrid')?.classList.contains('pane-focus-mode'), ); await page.locator(paneSelector).dblclick(); await page.waitForFunction( (targetSelector) => { const grid = document.querySelector('#workspaceGrid'); const pane = document.querySelector(targetSelector); return Boolean( grid?.classList.contains('pane-focus-mode') && pane?.classList.contains('is-maximized'), ); }, paneSelector, ); await page.keyboard.press('Escape'); await page.waitForFunction( () => !document.querySelector('#workspaceGrid')?.classList.contains('pane-focus-mode'), ); } async function assertMascotVisibility(page, shouldShow) { await page.waitForFunction( (expectedVisible) => { const overlay = document.querySelector('.chart-bg-overlay'); if (!overlay) { return false; } const overlayOpacity = Number.parseFloat(getComputedStyle(overlay).opacity || '0'); const mascotOpacity = Number.parseFloat(getComputedStyle(overlay, '::before').opacity || '0'); return expectedVisible ? overlayOpacity > 0.01 && mascotOpacity > 0.01 : overlayOpacity <= 0.01 || mascotOpacity <= 0.01; }, shouldShow, ); } async function assertChartOnlyToggle(page) { await page.waitForFunction(() => document.body.dataset.chartOnly === 'false'); await page.locator('#chartOnlyToggle').click(); await page.waitForFunction(() => document.body.dataset.chartOnly === 'true'); await page.waitForFunction( () => document.querySelector('#chartOnlyToggle')?.getAttribute('aria-pressed') === 'true', ); await page.waitForFunction(() => { const statusWrap = document.querySelector('.status-wrap'); const chartGauges = document.querySelector('#chartGauges'); if (!(statusWrap instanceof HTMLElement) || !(chartGauges instanceof HTMLElement)) { return false; } return getComputedStyle(statusWrap).display === 'none' && getComputedStyle(chartGauges).display === 'none'; }); await assertMascotVisibility(page, false); await page.locator('#chartOnlyToggle').click(); await page.waitForFunction(() => document.body.dataset.chartOnly === 'false'); await page.waitForFunction( () => document.querySelector('#chartOnlyToggle')?.getAttribute('aria-pressed') === 'false', ); } async function assertWorkspaceRestore(page) { const restoredState = { version: 4, layoutPreset: 4, activePaneId: 'pane-2', forecastSettings: { contextLength: 384 }, panes: [ { id: 'pane-0', symbol: 'DXY', interval: '4h', indicator: 'none', horizon: 24, aiModels: { kronos: true, timesfm: true, chronos: false }, }, { id: 'pane-1', symbol: 'EURX', interval: '4h', indicator: 'none', horizon: 24, aiModels: { kronos: true, timesfm: false, chronos: true }, }, { id: 'pane-2', symbol: 'GBPX', interval: '4h', indicator: 'none', horizon: 24, aiModels: { kronos: true, timesfm: true, chronos: true }, }, { id: 'pane-3', symbol: 'JPYX', interval: '4h', indicator: 'none', horizon: 24, aiModels: { kronos: false, timesfm: true, chronos: true }, }, ], }; await page.evaluate((workspaceState) => { window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(workspaceState)); }, restoredState); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#workspaceGrid'); await assertPaneCount(page, 4); await page.waitForSelector('.workspace-grid.layout-4'); await waitForPaneLabel(page, '.chart-pane[data-pane-id="pane-2"] .pane-symbol', 'GBPX'); await waitForPaneLabel(page, '.chart-pane[data-pane-id="pane-3"] .pane-symbol', 'JPYX'); await page.waitForFunction(() => document.querySelector('#timeframeSelect')?.value === '4h'); await page.waitForFunction(() => document.querySelector('#horizonInput')?.value === '24'); } function attachForecastRequestCapture(page) { const requests = []; const handleRequest = (request) => { try { const url = new URL(request.url()); if (!url.pathname.startsWith('/api/forecast/')) { return; } requests.push({ symbol: decodeURIComponent(url.pathname.split('/').pop() || ''), contextLength: Number(url.searchParams.get('context_length') || '0'), refresh: url.searchParams.get('refresh'), stage: url.searchParams.get('stage') || 'interactive', requestId: url.searchParams.get('request_id') || '', }); } catch (_) { // Ignore malformed request URLs captured by the browser. } }; page.on('request', handleRequest); return { requests, dispose() { page.off('request', handleRequest); }, }; } async function assertWorkspaceContextForecastRequests(page, forecastRequests, expectedSymbol) { await page.waitForFunction( (symbol) => { const pane = window.Workspace?.getPane?.('pane-0'); return Boolean( pane && pane.symbol === symbol && pane.aiSession?.committedSnapshot?.payload, ); }, expectedSymbol, { timeout: 240000 }, ); const symbolRequests = forecastRequests.filter((request) => request.symbol === expectedSymbol); const bootstrapIndex = symbolRequests.findIndex((request) => ( request.stage === 'bootstrap' && request.contextLength === 384 && request.refresh === 'false' )); if (bootstrapIndex < 0) { throw new Error(`Missing bootstrap forecast request for ${expectedSymbol}`); } if (!symbolRequests[bootstrapIndex]?.requestId) { throw new Error(`Missing request_id on bootstrap forecast request for ${expectedSymbol}`); } } async function main() { const baseUrl = process.env.AIFORECAST_BASE_URL || 'http://127.0.0.1:8000'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); try { const forecastCapture = attachForecastRequestCapture(page); await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await Promise.all([ page.waitForSelector('#workspaceGrid'), page.waitForSelector('#timeframeSelect'), page.waitForSelector('#horizonInput'), ]); await assertModelAvailabilityMatchesHealth(page); await switchLayout(page, 1); await assertMascotVisibility(page, true); await assertChartOnlyToggle(page); await page.evaluate(() => { if (typeof window.explorerSelectSymbol === 'function') { window.explorerSelectSymbol('EURUSD'); } }); await page.waitForFunction(() => document.querySelector('#symbolSearch')?.value === 'EURUSD'); await dispatchControlValue(page, '#timeframeSelect', '1h'); await page.waitForFunction(() => document.querySelector('#timeframeSelect')?.value === '1h'); await dispatchControlValue(page, '#horizonInput', '24'); await page.waitForFunction(() => document.querySelector('#horizonInput')?.value === '24'); await assertWorkspaceContextForecastRequests(page, forecastCapture.requests, 'EURUSD'); for (const preset of [2, 4, 8]) { await switchLayout(page, preset); await assertMascotVisibility(page, false); } await assertFocusMode(page, '.chart-pane[data-pane-id="pane-1"]'); await assertWorkspaceRestore(page); forecastCapture.dispose(); } finally { await browser.close(); } } main().catch((error) => { console.error('[playwright-smoke] failed:', error); process.exitCode = 1; });