266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
/**
|
|
* The chart shows the right window, at the right density, without throwing.
|
|
*
|
|
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
|
|
* tests passed, which is the whole argument for this file existing.
|
|
*/
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
withChart, launch, openChart, chartState, selectTimeframe, assertNoPageErrors,
|
|
} from './helpers.mjs';
|
|
|
|
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
|
|
|
test('a first-time browser exchanges the friendly password for a session',
|
|
{ timeout: 180000 }, async () => {
|
|
const { browser, page } = await launch();
|
|
let statusRequests = 0;
|
|
let submitted = null;
|
|
try {
|
|
page.on('dialog', async dialog => {
|
|
assert.equal(dialog.message(), 'Password for this chart');
|
|
await dialog.accept('friendly passphrase');
|
|
});
|
|
await page.route('**/api/status', async route => {
|
|
statusRequests += 1;
|
|
if (statusRequests === 1) await route.fulfill({ status: 401, body: '{}' });
|
|
else await route.continue();
|
|
});
|
|
await page.route('**/api/login', async route => {
|
|
submitted = route.request().postDataJSON();
|
|
await route.fulfill({
|
|
status: 204,
|
|
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
|
|
});
|
|
});
|
|
|
|
await openChart(page);
|
|
|
|
assert.deepEqual(submitted, { password: 'friendly passphrase' });
|
|
assert.ok(statusRequests >= 2, 'status was not retried after login');
|
|
const unexpected = page.__errors.filter(error =>
|
|
!error.includes('server responded with a status of 401'));
|
|
assert.deepEqual(unexpected, [], `page errors: ${unexpected.join('; ')}`);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('an existing browser token is migrated once and removed from local storage',
|
|
{ timeout: 180000 }, async () => {
|
|
const { browser, page } = await launch();
|
|
let presentedToken = null;
|
|
try {
|
|
await page.addInitScript(() => localStorage.setItem('chart-token', 'legacy-token'));
|
|
await page.route('**/api/login', async route => {
|
|
presentedToken = route.request().headers()['x-chart-token'];
|
|
await route.fulfill({
|
|
status: 204,
|
|
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
|
|
});
|
|
});
|
|
|
|
await openChart(page);
|
|
|
|
assert.equal(presentedToken, 'legacy-token');
|
|
assert.equal(await page.evaluate(() => localStorage.getItem('chart-token')), null);
|
|
assertNoPageErrors(page, assert);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('diagnostic capture uploads a PNG and adds its capability ID to Events',
|
|
{ timeout: 180000 }, async () => {
|
|
const { browser, page } = await launch();
|
|
let uploaded = null;
|
|
try {
|
|
await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
|
|
await page.route('**/api/debug/captures', async route => {
|
|
uploaded = {
|
|
contentType: route.request().headers()['content-type'],
|
|
metadata: JSON.parse(Buffer.from(
|
|
route.request().headers()['x-capture-metadata'], 'base64').toString()),
|
|
};
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
id: 'c-TESTCAPTURE1',
|
|
url: '/api/debug/captures/c-TESTCAPTURE1',
|
|
}),
|
|
});
|
|
});
|
|
await openChart(page);
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
|
|
configurable: true,
|
|
set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
|
|
});
|
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
|
|
configurable: true, get: () => 800,
|
|
});
|
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
|
|
configurable: true, get: () => 600,
|
|
});
|
|
HTMLMediaElement.prototype.play = async function play() {};
|
|
HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
|
|
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
|
HTMLCanvasElement.prototype.getContext = function getContext(...args) {
|
|
return this.closest('#chart') ? realGetContext.apply(this, args) : { drawImage() {} };
|
|
};
|
|
HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
|
|
done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
|
|
type: 'image/png',
|
|
}));
|
|
};
|
|
});
|
|
|
|
await page.keyboard.press('Alt+Shift+C');
|
|
await page.waitForFunction(() =>
|
|
document.querySelector('.diag-capture')?.textContent.includes('CAPTURE IN'));
|
|
await page.waitForFunction(() =>
|
|
[...document.querySelectorAll('.alert-entry')]
|
|
.some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
|
|
|
|
assert.equal(uploaded.contentType, 'image/png');
|
|
assert.equal(uploaded.metadata.timeframe, '1m');
|
|
assert.equal(uploaded.metadata.image_width, 800);
|
|
assert.equal(uploaded.metadata.image_height, 600);
|
|
const event = page.locator('.alert-entry.capture a');
|
|
assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
|
|
assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
|
|
assertNoPageErrors(page, assert);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('the viewport opens on the live edge, not in the past', {
|
|
timeout: 180000,
|
|
skip: 'quarantined: chart data and viewport are sampled separately while the live feed advances',
|
|
}, async () => {
|
|
await withChart(async page => {
|
|
const state = await chartState(page);
|
|
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
|
|
// The bug: setBars placed the viewport by logical index, and the daily MA
|
|
// series then prepended hundreds of points, sliding the view ~10 hours back
|
|
// while the data itself was perfectly current.
|
|
assert.equal(
|
|
state.visibleTo, state.lastTime,
|
|
'the newest bar is not the right-hand edge of the viewport',
|
|
);
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|
|
|
|
test('the status row shows the exact local time data last arrived',
|
|
{ timeout: 180000 }, async () => {
|
|
await withChart(async page => {
|
|
await page.waitForFunction(() => {
|
|
const freshness = document.querySelector('.data-freshness b');
|
|
return freshness?.textContent.trim() !== '—';
|
|
});
|
|
const state = await page.evaluate(() => {
|
|
const freshness = document.querySelector('.data-freshness span:last-child');
|
|
return {
|
|
freshness: freshness.textContent.trim(),
|
|
deployMetadata: document.querySelector('.app-meta'),
|
|
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
|
|
};
|
|
});
|
|
assert.match(state.freshness, /^UPDATED\s+\d{1,2}:\d{2}:\d{2}\s*(AM|PM)?$/i);
|
|
assert.equal(state.deployMetadata, null);
|
|
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|
|
|
|
test('the quote, current-price marker, and tool order retain their trading context',
|
|
{ timeout: 180000 }, async () => {
|
|
await withChart(async page => {
|
|
const state = await page.evaluate(() => ({
|
|
quote: document.querySelector('.quote-change').textContent.trim(),
|
|
sections: [...document.querySelectorAll('aside > .sidebar-section > summary')]
|
|
.map(node => node.textContent.trim().replace(/\s+/g, ' ')),
|
|
tools: [...document.querySelectorAll('.tools-section > .tool')]
|
|
.map(node => node.querySelector('.tool-head').textContent.trim().replace(/\s+/g, ' ')),
|
|
candleOptions: window.__chart.candles.options(),
|
|
markerOptions: window.__chart.currentPriceLine?.options(),
|
|
pulseExists: Boolean(document.querySelector('.current-price-pulse')),
|
|
configOpen: document.querySelector('.config-section').open,
|
|
}));
|
|
|
|
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
|
|
assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']);
|
|
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Fibonacci', 'Trendline', 'Comment']);
|
|
assert.equal(state.candleOptions.lastValueVisible, false);
|
|
assert.equal(state.candleOptions.priceLineVisible, false);
|
|
assert.equal(state.markerOptions.lineWidth, 1);
|
|
assert.equal(state.markerOptions.axisLabelVisible, true);
|
|
assert.equal(state.pulseExists, true);
|
|
assert.equal(state.configOpen, false);
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|
|
|
|
test('a daily crosshair shows the session date, not the previous evening',
|
|
{ timeout: 180000 }, async () => {
|
|
await withChart(async page => {
|
|
await selectTimeframe(page, '1d');
|
|
const state = await page.evaluate(() => {
|
|
const c = window.__chart;
|
|
const time = c.bars[c.bars.length - 1].t;
|
|
return {
|
|
actual: c.chart.options().localization.timeFormatter(time),
|
|
expected: new Date(time * 1000).toLocaleString(undefined, {
|
|
timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
|
}),
|
|
};
|
|
});
|
|
assert.equal(state.actual, state.expected);
|
|
assert.doesNotMatch(state.actual, /\d:\d/);
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|
|
|
|
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
|
|
await withChart(async page => {
|
|
for (const tf of TIMEFRAMES) {
|
|
await selectTimeframe(page, tf);
|
|
const state = await chartState(page);
|
|
// The bug: a minute-resolution VWAP put its own points on the shared time
|
|
// scale, so an hourly chart spread 160 candles over 908 slots and drew
|
|
// them as slivers. Anything much above 1.0 means the scale is polluted.
|
|
const ratio = state.slots / Math.max(state.inView, 1);
|
|
assert.ok(
|
|
ratio > 0.8 && ratio < 1.3,
|
|
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
|
|
);
|
|
}
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|
|
|
|
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
|
|
await withChart(async page => {
|
|
// The bug: bar events for the timeframe just left arrived after the new
|
|
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
|
|
// rather than ignoring a stale bar, which took the whole app down.
|
|
for (let round = 0; round < 3; round += 1) {
|
|
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
|
|
await page.click(`.timeframes button:text-is("${tf}")`);
|
|
await page.waitForTimeout(700);
|
|
}
|
|
}
|
|
await page.waitForTimeout(2000);
|
|
const state = await chartState(page);
|
|
assert.ok(state.bars > 0, 'the chart lost its data while switching');
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|