← back to Stock Vshape Viewer

test/panel.spec.mjs

124 lines

// Panel behavior spec — opens the collapsed #left hamburger and exercises the controls
// the /3x click-through can't reach. Run: node test/panel.spec.mjs [url]
// Needs the server running (default http://localhost:9822/). Uses global Playwright.
import { createRequire } from 'module';
import os from 'os';
import path from 'path';
const require = createRequire(import.meta.url);

const URL = process.argv[2] || 'http://localhost:9822/';
const CREDS = { username: 'admin', password: 'DW2024!' };

// resolve global Playwright
let pw;
for (const p of [
  path.join(os.homedir(), '.npm-global/lib/node_modules/playwright'),
  '/usr/local/lib/node_modules/playwright', 'playwright',
]) { try { pw = require(p); break; } catch {} }
if (!pw) { console.error('Playwright not found (npm i -g playwright)'); process.exit(2); }

async function launch() {
  for (const opt of [{ channel: 'chrome' }, {}, { executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }]) {
    try { return await pw.chromium.launch(opt); } catch {}
  }
  throw new Error('could not launch a chromium/chrome');
}

let pass = 0, fail = 0;
const ok = (name, cond, detail = '') => { (cond ? (pass++, console.log('  ✅', name, detail)) : (fail++, console.log('  ❌', name, detail))); };

const run = async () => {
  const browser = await launch();
  const ctx = await browser.newContext({ httpCredentials: CREDS, viewport: { width: 1440, height: 1000 } });
  await ctx.addInitScript(() => { try { localStorage.clear(); } catch {} }); // deterministic seed
  const page = await ctx.newPage();

  const jsErrors = [];
  page.on('pageerror', e => jsErrors.push(String(e)));
  page.on('console', m => { if (m.type() === 'error' && !/favicon/.test(m.text())) jsErrors.push(m.text()); });

  console.log(`\n/panel-spec → ${URL}\n`);
  await page.goto(URL);
  await page.waitForFunction(() => document.querySelectorAll('#wl .item').length >= 6 && /live/.test(document.getElementById('live-txt').textContent), { timeout: 25000 });
  ok('live data loads (seed watchlist)', true);

  // open the hamburger panel
  await page.click('#burger');
  ok('panel opens', await page.evaluate(() => document.getElementById('left').classList.contains('open')));

  // --- SCAN ---
  const wlBefore = await page.evaluate(() => document.querySelectorAll('#wl .item').length);
  await page.evaluate(() => { const s = document.getElementById('scan-max'); s.value = 120; s.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.click('#scan-btn');
  await page.waitForFunction(() => document.getElementById('scan-prog').textContent.includes('loaded top'), { timeout: 45000 });
  const scan = await page.evaluate(() => ({
    matches: +((document.getElementById('scan-prog').textContent.match(/(\d+) matches/) || [])[1] || 0),
    results: document.querySelectorAll('#scan-results .r').length,
    wl: document.querySelectorAll('#wl .item').length,
  }));
  ok('scan → finds matches', scan.matches > 0, `${scan.matches} matches`);
  ok('scan → renders results list', scan.results > 0, `${scan.results} rows`);
  ok('scan → auto-loads board', scan.wl > wlBefore, `${wlBefore}→${scan.wl}`);

  // --- ADD ---
  await page.fill('#add-tk', 'AAPL');
  await page.click('#add-btn');
  await page.waitForFunction(() => [...document.querySelectorAll('#wl .tk')].some(e => e.textContent === 'AAPL'), { timeout: 15000 });
  ok('add ticker → AAPL on watchlist', true);

  // --- RESET WATCHLIST ---
  await page.click('#reset-watchlist');
  await page.waitForFunction(() => document.querySelectorAll('#wl .item').length === 6, { timeout: 15000 });
  ok('reset watchlist → back to seed 6', true);

  // --- RESET ALL ---
  await page.evaluate(() => { const m = document.getElementById('f-minDip'); m.value = 25; m.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.click('#reset-all');
  await page.waitForTimeout(600);
  const ra = await page.evaluate(() => ({ minDip: document.getElementById('v-minDip').textContent, wl: document.querySelectorAll('#wl .item').length }));
  ok('reset all → minDip default 10%', ra.minDip === '10%', ra.minDip);
  ok('reset all → seed watchlist', ra.wl === 6, String(ra.wl));

  // --- OPTIONS ---
  await page.evaluate(() => { const n = [...document.querySelectorAll('#wl .item')].find(it => it.querySelector('.tk')?.textContent === 'NFLX'); n && n.click(); });
  await page.waitForSelector('#detail.show', { timeout: 8000 });
  await page.click('#d-opttoggle');
  await page.waitForFunction(() => { const b = document.getElementById('d-opts'); return b && (b.querySelector('table') || /No contracts|no options/i.test(b.textContent)); }, { timeout: 20000 });
  const opt = await page.evaluate(() => ({ hasTable: !!document.querySelector('#d-opts table'), rows: document.querySelectorAll('#d-opts table tr').length }));
  ok('options → chain loads', opt.hasTable || true, opt.hasTable ? `${opt.rows} rows` : 'no qualifying contracts (valid)');
  await page.evaluate(() => { const p = [...document.querySelectorAll('#seg-optType button')].find(b => b.dataset.v === 'puts'); p.click(); });
  await page.waitForTimeout(1500);
  ok('options → type toggle (Puts)', await page.evaluate(() => document.querySelector('#seg-optType button[data-v="puts"]').classList.contains('on')));

  // --- PRESET ---
  await page.evaluate(() => document.querySelector('#presets .p[data-i="0"]').click());
  await page.waitForTimeout(600);
  ok('preset → Deep V-Recovery sets dip≥20%', await page.evaluate(() => document.getElementById('crit-dip').textContent === '≥20%'));

  // --- BRUSH (custom range) ---
  await page.evaluate(() => { const n = [...document.querySelectorAll('#wl .item')].find(it => it.querySelector('.tk')?.textContent === 'NFLX'); n && n.click(); });
  await page.waitForSelector('#detail.show', { timeout: 8000 });
  const box = await page.evaluate(() => { const r = document.getElementById('d-brush').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
  await page.mouse.move(box.x + box.w * 0.5, box.y + box.h / 2);
  await page.mouse.down();
  await page.mouse.move(box.x + box.w * 0.85, box.y + box.h / 2, { steps: 6 });
  await page.mouse.up();
  await page.waitForTimeout(500);
  ok('brush → sets custom range', await page.evaluate(() => /Custom range/.test(document.getElementById('d-vnote').textContent)));

  // --- SCORE BACKTEST ---
  await page.evaluate(() => document.getElementById('d-bttoggle').click());
  await page.waitForTimeout(2500);
  const bt = await page.evaluate(() => document.getElementById('d-backtest').textContent);
  ok('backtest panel responds (building or data, no error)', /names|building|forward return|Read|correlation/.test(bt) && !/error/i.test(bt), bt.slice(0, 40));

  // --- console cleanliness ---
  ok('no JS/console errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | '));

  await browser.close();
  console.log(`\n  ${pass} passed · ${fail} failed\n`);
  process.exit(fail ? 1 : 0);
};

run().catch(e => { console.error('spec crashed:', e); process.exit(1); });