Snapping took the bar sharing the cursor's time and then its nearer extreme, which ignored how far away that extreme was. Pointing below a candle snapped to that candle's low however distant, while the extreme genuinely under the cursor was never considered. Zoomed out to some 360 bars at three pixels apart, that made hitting the bar you meant a matter of several tries. snapPoint now scans six bars either side and takes the extreme nearest in pixels. Proven by probe: with the cursor sitting exactly on one bar's low but nudged two pixels so coordinateToTime resolves to its neighbour, the snap takes the extreme under the cursor rather than the neighbour's. A report of the snap dot appearing "way above the bar" turned out to be the dot landing correctly on the low while the cursor was 151 points below it: the right price scale keeps a bottom margin of 0.1 and the volume overlay is drawn in it, so the lower fifth of the pane sits below every candle. bin/e2e runs tests/e2e against the dev stack inside the playwright service — Node's own test runner, no dependency added here, since Playwright is global in that container. Eleven cases, each one a bug that shipped: the viewport parked ten hours in the past, hourly candles drawn as slivers, stale bar events throwing, comments drifting across a timeframe switch, and three ways a trendline anchor could disagree with its preview. Not one was reachable from pytest. Tests delete any drawing they create, because the dev store is shared with whoever is looking at the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
6 KiB
JavaScript
161 lines
6 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();
|
|
let before = [];
|
|
try {
|
|
await openChart(page);
|
|
before = (await drawingIds(page));
|
|
await body(page);
|
|
} finally {
|
|
try {
|
|
const after = await drawingIds(page);
|
|
const created = after.filter(id => !before.includes(id));
|
|
if (created.length) 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(' | ')}`);
|
|
}
|