/** * Shared plumbing for the end-to-end tests. * * These run inside the `playwright` compose service, which has Playwright * installed globally and can reach the app as http://api:8000. Nothing here * needs npm install in this repo — see bin/e2e. */ // Playwright is installed globally in the container, not in this repo. ESM // ignores NODE_PATH, so it is resolved through a CommonJS require, which // honours it — see bin/e2e. import { createRequire } from 'node:module'; const { chromium } = createRequire(import.meta.url)('playwright'); export const APP_URL = process.env.E2E_URL || 'http://api:8000/'; const TOKEN_KEY = 'chart-token'; /** * Launch a browser pointed at the chart. * * `--lang=en-US` is not optional. The container has no usable locale, so * Chromium reports `en-US@posix`, Intl throws inside the charting library, and * every test sees a blank canvas that looks exactly like a broken app. */ export async function launch(options = {}) { const browser = await chromium.launch({ args: ['--lang=en-US'], env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' }, }); const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, locale: 'en-US', timezoneId: 'America/Chicago', ...options, }); const errors = []; page.on('pageerror', error => errors.push(String(error.message))); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.__errors = errors; return { browser, page }; } /** Open the app and wait until the chart has bars and levels. */ export async function openChart(page) { await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForFunction( () => window.__chart && window.__chart.bars && window.__chart.bars.length > 0, { timeout: 60000 }, ); // The socket delivers a second snapshot on subscribe; let it settle so the // viewport is the one the app actually ends up showing. await page.waitForTimeout(3000); return page; } /** * Run `body` against a fresh browser, then close it. * * Any drawing created during the test is deleted afterwards, whatever the * outcome. The dev stack shares one drawing store with whoever is using the * app, so a test that leaves debris leaves it in someone's sidebar. */ export async function withChart(body, launchOptions = {}) { const { browser, page } = await launch(launchOptions); const created = new Set(); const pendingTracking = new Set(); const trackCreatedDrawing = async response => { const request = response.request(); const path = new URL(response.url()).pathname; if (request.method() !== 'POST' || !['/api/lines', '/api/lines/price', '/api/comments'].includes(path) || !response.ok()) return; try { const drawing = await response.json(); if (drawing.id) created.add(drawing.id); } catch { /* a successful drawing response should be JSON; cleanup stays best-effort */ } }; const responseListener = response => { const pending = trackCreatedDrawing(response) .finally(() => pendingTracking.delete(pending)); pendingTracking.add(pending); }; page.on('response', responseListener); try { await openChart(page); await body(page); } finally { page.off('response', responseListener); try { await Promise.all(pendingTracking); if (created.size) await deleteDrawings(page, [...created]); } catch { /* the page may already be gone; nothing to clean */ } await browser.close(); } } /** Ids of every drawing the server currently holds. */ export async function drawingIds(page) { return page.evaluate(async key => { const token = localStorage.getItem(key) || ''; const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} }); if (!response.ok) return []; return (await response.json()).drawings.map(drawing => drawing.id); }, TOKEN_KEY); } export async function deleteDrawings(page, ids) { return page.evaluate(async ([list, key]) => { const token = localStorage.getItem(key) || ''; for (const id of list) { await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {}, }); } }, [ids, TOKEN_KEY]); } /** Pixel box of the chart pane, for turning fractions into mouse positions. */ export async function chartBox(page) { return page.evaluate(() => { const rect = document.querySelector('#chart').getBoundingClientRect(); return { x: rect.x, y: rect.y, w: rect.width, h: rect.height }; }); } /** A point inside the chart, given as fractions of its width and height. */ export function at(box, fx, fy) { return { x: box.x + box.w * fx, y: box.y + box.h * fy }; } export async function selectTimeframe(page, tf) { await page.click(`.timeframes button:text-is("${tf}")`); await page.waitForTimeout(3000); } export async function armTool(page, name) { await page.click(`.tool-head:has-text("${name}")`); await page.waitForTimeout(400); } /** The newest hand-drawn sloped line, with the bar its anchor sits on. */ export async function newestTrendline(page) { return page.evaluate(() => { const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0); const line = lines.sort((a, b) => b.number - a.number)[0]; if (!line) return null; const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null; return { id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side, onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null, snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null, }; }); } /** What the chart is currently showing: series, viewport, and slot density. */ export async function chartState(page) { return page.evaluate(() => { const chart = window.__chart; const data = chart.candles.data(); const visible = chart.chart.timeScale().getVisibleRange(); const logical = chart.chart.timeScale().getVisibleLogicalRange(); const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0; return { bars: data.length, lastTime: data.length ? data[data.length - 1].time : null, lastClose: data.length ? data[data.length - 1].close : null, inView, slots: logical ? Math.round(logical.to - logical.from) : 0, visibleFrom: visible ? visible.from : null, visibleTo: visible ? visible.to : null, }; }); } /** Fails the test if the page logged any error, with the first few quoted. */ export function assertNoPageErrors(page, assert) { const unique = [...new Set(page.__errors)]; assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`); }