- 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.
178 lines
6.7 KiB
JavaScript
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(' | ')}`);
|
|
}
|