Spaces:
Running
Running
| 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; | |
| }); | |