← back to Cronjobs Viewer

scripts/cycle2-screenrecord.js

89 lines

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright');

const PROJECT = path.resolve(__dirname, '..');
const URL = process.env.CJV_TEST_URL || 'http://127.0.0.1:19772/';
const ROOT = path.join(PROJECT, 'verification', 'cycle2-screenrecord');
const LOG = path.join(ROOT, 'debug-log.jsonl');
const AUTH = { username: process.env.CJV_USER || 'admin', password: process.env.CJV_PASS || 'DW2024!' };
fs.mkdirSync(path.join(ROOT, 'rec'), { recursive: true });
const prior = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : [];
const priorBad = new Set(prior.filter(x => !x.ok || (x.errors || []).length).map(x => x.label));
const current = [];
const append = row => { current.push(row); fs.appendFileSync(LOG, JSON.stringify(row) + '\n'); };

async function act(page, run, label, action, assertion) {
  const before = page.__errors.length;
  let ok = true, effect = 'expected state observed';
  try { await action(); await page.waitForTimeout(80); ok = !!(await assertion()); if (!ok) effect = 'assertion returned false'; }
  catch (error) { ok = false; effect = error.message; }
  const errors = page.__errors.splice(before);
  append({ run, ts: new Date().toISOString(), label, ok: ok && !errors.length, effect, errors });
}

async function run(run) {
  const browser = await chromium.launch({ headless: true });
  const dir = path.join(ROOT, 'rec', `run${run}`); fs.mkdirSync(dir, { recursive: true });
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, httpCredentials: AUTH, recordVideo: { dir, size: { width: 1440, height: 1000 } } });
  const page = await ctx.newPage(); page.__errors = [];
  page.on('console', m => m.type() === 'error' && page.__errors.push(`console: ${m.text()}`));
  page.on('pageerror', e => page.__errors.push(`pageerror: ${e.message}`));
  page.on('response', r => r.status() >= 400 && page.__errors.push(`HTTP ${r.status()}: ${r.url()}`));
  await page.goto(URL, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.querySelectorAll('.card').length > 0);
  const data = await page.evaluate(() => fetch('api/crons', { credentials: 'same-origin' }).then(r => r.json()));
  const alerts = data.jobs.filter(x => x.state === 'alert').length, errors = data.jobs.filter(x => x.state === 'error').length;
  if (run === 4 && priorBad.size) append({ run, ts: new Date().toISOString(), label: 'prior failures first', ok: true, effect: [...priorBad].join(', '), errors: [] });

  const groups = {
    filters: async () => {
      await act(page, run, 'Alert filter', () => page.locator('#fstate').selectOption('alert'), async () => (await page.locator('.card.alert').count()) === alerts && (await page.locator('.card:not(.alert)').count()) === 0);
      await act(page, run, 'State drill link', () => page.locator('.card.alert .drill').first().click(), () => page.evaluate(() => new URL(location.href).searchParams.get('state') === 'alert'));
      await act(page, run, 'Error filter', () => page.locator('#fstate').selectOption('error'), async () => (await page.locator('.card.error').count()) === errors && (await page.locator('.card:not(.error)').count()) === 0);
      await act(page, run, 'All states', () => page.locator('#fstate').selectOption(''), async () => (await page.locator('.card').count()) === data.jobs.length);
      await act(page, run, 'Failing only', () => page.locator('#failonly').check(), async () => (await page.locator('.card').count()) === alerts + errors);
      await act(page, run, 'Failing off', () => page.locator('#failonly').uncheck(), async () => (await page.locator('.card').count()) === data.jobs.length);
    },
    search: async () => {
      const q = data.jobs.find(x => x.state === 'alert' || x.state === 'error').label.replace(/^com\.steve\./,'').slice(0,10);
      await act(page, run, 'Search', () => page.locator('#q').fill(q), async () => (await page.locator('.card').count()) > 0);
      await act(page, run, 'Clear search', () => page.locator('#q').fill(''), async () => (await page.locator('.card').count()) === data.jobs.length);
    },
    sort: async () => { for (const v of ['state','stale','label','runs','nextRun','lastLog','schedule','exit','created']) await act(page, run, `Sort ${v}`, () => page.locator('#sort').selectOption(v), async () => (await page.locator('#sort').inputValue()) === v); },
    density: async () => { for (const v of run % 2 ? ['6','1','4'] : ['1','6','4']) await act(page, run, `Density ${v}`, () => page.locator('#density').fill(v), () => page.evaluate(x => getComputedStyle(document.documentElement).getPropertyValue('--cols').trim() === x, v)); },
    selects: async () => {
      for (const v of run % 2 ? ['unknown','stale','firing',''] : ['','firing','stale','unknown']) await act(page, run, `Liveness ${v || 'all'}`, () => page.locator('#flive').selectOption(v), async () => (await page.locator('#flive').inputValue()) === v);
      await page.locator('#flive').selectOption('');
      for (const v of run % 2 ? ['at load','every','daily',''] : ['','daily','every','at load']) await act(page, run, `Cadence ${v || 'all'}`, () => page.locator('#fcad').selectOption(v), async () => (await page.locator('#fcad').inputValue()) === v);
      await page.locator('#fcad').selectOption('');
    },
    toggles: async () => {
      await act(page, run, 'Auto off', () => page.locator('#auto').uncheck(), async () => !(await page.locator('#auto').isChecked()));
      await act(page, run, 'Auto on', () => page.locator('#auto').check(), async () => await page.locator('#auto').isChecked());
    },
    liveness: async () => await act(page, run, 'Liveness pill', async () => { const p=page.locator('.live').first(); await p.click(); await page.waitForFunction(() => !document.querySelector('.live[data-busy]'), null, { timeout: 15000 }); }, async () => /firing|stale|unknown|running now/.test(await page.locator('.live').first().textContent()))
  };
  const orders = [
    ['filters','search','sort','density','selects','toggles','liveness'],
    ['liveness','toggles','selects','density','sort','search','filters'],
    ['density','filters','sort','liveness','search','toggles','selects'],
    ['search','selects','liveness','filters','toggles','sort','density'],
    ['sort','liveness','density','toggles','filters','selects','search']
  ];
  for (const name of orders[run]) await groups[name]();
  append({ run, ts: new Date().toISOString(), label: 'run complete', ok: page.__errors.length === 0, effect: orders[run].join(' → '), errors: page.__errors.splice(0) });
  const video = page.video(); await ctx.close(); const videoPath = video ? await video.path() : ''; await browser.close(); return { videoPath, order: orders[run] };
}

(async () => {
  const runs = []; for (let i=0;i<5;i++) runs.push(await run(i));
  const bad = current.filter(x => !x.ok || x.errors.length);
  const report = ['# Cycle 2 five-run screen record — cronjobs viewer', '', `**Verdict:** ${bad.length ? 'FAIL' : 'PASS'} — ${current.length - bad.length}/${current.length} actions clean.`, '',
    'Persistent append-only memory: `debug-log.jsonl` (read before this run).', '', '## Runs', '',
    ...runs.map((x,i)=>`- Run ${i}: ${x.order.join(' → ')} — ${x.videoPath}`), '', '## Failures / order-dependent defects', '', bad.length ? bad.map(x=>`- run ${x.run} ${x.label}: ${x.effect}; ${x.errors.join('; ')}`).join('\n') : 'None.', ''];
  fs.writeFileSync(path.join(ROOT, 'DEBUG-REPORT.md'), report.join('\n'));
  console.log(JSON.stringify({ verdict: bad.length ? 'FAIL' : 'PASS', runs: 5, actions: current.length, failures: bad.length, report: path.join(ROOT, 'DEBUG-REPORT.md') }));
  if (bad.length) process.exitCode=1;
})().catch(e=>{console.error(e);process.exit(1)});