chart/tests/e2e/helpers.mjs
Chris Amow 3b3c06a1f3 - Drag a selected trendline body to reposition the entire line.
- Duplicate and delete from the line context menu.
     - Copies shift ten bars right.
     - Default names are up and down; copies become up 2, down 2, etc.
     - Exact local data receipt time including seconds.
     - Deployment timestamp removed.
     - Test cleanup no longer deletes drawings created from your browser.
     - JWT password session flow.
2026-08-11 05:46:09 -05:00

178 lines
6.7 KiB
JavaScript

/**
* 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() {
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',
});
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) {
const { browser, page } = await launch();
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(' | ')}`);
}