← back to Cronjobs Viewer

scripts/cycle2-cta.js

94 lines

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { chromium, webkit } = 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-cta');
const AUTH = { username: process.env.CJV_USER || 'admin', password: process.env.CJV_PASS || 'DW2024!' };
fs.mkdirSync(path.join(ROOT, 'shots'), { recursive: true });
fs.mkdirSync(path.join(ROOT, 'rec'), { recursive: true });
const results = [];

async function check(page, engine, label, action, assertion) {
  const before = page.__errors.length;
  let ok = true, detail = '';
  try {
    await action();
    await page.waitForTimeout(100);
    ok = !!(await assertion());
    if (!ok) detail = 'assertion returned false';
  } catch (error) { ok = false; detail = error.message; }
  const errors = page.__errors.slice(before);
  results.push({ engine, label, ok: ok && !errors.length, detail, errors });
}

async function run(engine, browserType) {
  const browser = await browserType.launch({ headless: true });
  const recDir = path.join(ROOT, 'rec', engine);
  const shotDir = path.join(ROOT, 'shots', engine);
  fs.mkdirSync(recDir, { recursive: true }); fs.mkdirSync(shotDir, { recursive: true });
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, httpCredentials: AUTH, recordVideo: { dir: recDir, 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, null, { timeout: 30000 });
  const api = await page.evaluate(() => fetch('api/crons', { credentials: 'same-origin' }).then(r => r.json()));
  const alerts = api.jobs.filter(x => x.state === 'alert'), errors = api.jobs.filter(x => x.state === 'error');
  await check(page, engine, 'alert/error source data', async () => {}, () => alerts.length > 0 && errors.length > 0);

  for (const value of ['state','stale','label','runs','nextRun','lastLog','schedule','exit','created']) {
    await check(page, engine, `sort ${value}`, () => page.locator('#sort').selectOption(value), async () => (await page.locator('#sort').inputValue()) === value && (await page.locator('.card').count()) > 0);
  }
  await check(page, engine, 'Alert filter', () => page.locator('#fstate').selectOption('alert'), async () => (await page.locator('.card').count()) === alerts.length && (await page.locator('.card:not(.alert)').count()) === 0);
  await page.screenshot({ path: path.join(shotDir, 'alert-filter.png'), fullPage: true });
  await check(page, engine, 'Alert badge rendered', async () => {}, async () => (await page.locator('.badge.alert').count()) === alerts.length && !(await page.locator('.card.alert .drill').first().textContent()).includes('<span'));
  await check(page, engine, 'State drill link', () => page.locator('.card.alert .drill').first().click(), () => page.evaluate(() => new URL(location.href).searchParams.get('state') === 'alert'));
  await page.goto(URL, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.querySelectorAll('.card').length > 0);
  await check(page, engine, 'Error filter', () => page.locator('#fstate').selectOption('error'), async () => (await page.locator('.card').count()) === errors.length && (await page.locator('.card:not(.error)').count()) === 0);
  await page.screenshot({ path: path.join(shotDir, 'error-filter.png'), fullPage: true });
  await check(page, engine, 'Error badges rendered', async () => {}, async () => (await page.locator('.badge.error').count()) === errors.length && !(await page.locator('.card.error .drill').first().textContent()).includes('<span'));
  await check(page, engine, 'All states', () => page.locator('#fstate').selectOption(''), async () => (await page.locator('.card').count()) === api.jobs.length);

  await check(page, engine, 'Failing only', () => page.locator('#failonly').check(), async () => (await page.locator('.card').count()) === alerts.length + errors.length && (await page.locator('.card:not(.alert):not(.error)').count()) === 0);
  await check(page, engine, 'Failing only off', () => page.locator('#failonly').uncheck(), async () => (await page.locator('.card').count()) === api.jobs.length);

  const query = (alerts[0] || errors[0]).label.replace(/^com\.steve\./, '').slice(0, 12);
  await check(page, engine, 'Search', () => page.locator('#q').fill(query), async () => (await page.locator('.card').count()) > 0 && (await page.locator('.card').allTextContents()).every(x => x.toLowerCase().includes(query.toLowerCase())));
  await page.locator('#q').fill('');

  for (const value of ['','firing','stale','unknown']) await check(page, engine, `liveness ${value || 'all'}`, () => page.locator('#flive').selectOption(value), async () => (await page.locator('#flive').inputValue()) === value);
  await page.locator('#flive').selectOption('');
  for (const value of ['','daily','every','at load']) await check(page, engine, `cadence ${value || 'all'}`, () => page.locator('#fcad').selectOption(value), async () => (await page.locator('#fcad').inputValue()) === value);
  await page.locator('#fcad').selectOption('');

  for (const value of ['1','6','4']) await check(page, engine, `density ${value}`, () => page.locator('#density').fill(value), () => page.evaluate(v => getComputedStyle(document.documentElement).getPropertyValue('--cols').trim() === v, value));
  await check(page, engine, 'Auto-refresh off', () => page.locator('#auto').uncheck(), async () => !(await page.locator('#auto').isChecked()));
  await check(page, engine, 'Auto-refresh on', () => page.locator('#auto').check(), async () => await page.locator('#auto').isChecked());

  await page.goto(URL, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.querySelectorAll('.card').length > 0);
  await check(page, engine, 'Liveness pill re-check', 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()));
  await page.screenshot({ path: path.join(shotDir, 'final.png'), fullPage: true });

  const video = page.video(); await ctx.close(); const videoPath = video ? await video.path() : ''; await browser.close();
  return { videoPath, alerts: alerts.length, errors: errors.length, jobs: api.jobs.length };
}

(async () => {
  const coverage = {};
  for (const [name, type] of [['chromium', chromium], ['webkit', webkit]]) coverage[name] = await run(name, type);
  const bad = results.filter(x => !x.ok);
  const report = ['# Cycle 2 CTA — cronjobs viewer', '', `**Verdict:** ${bad.length ? 'FAIL' : 'PASS'} — ${results.length - bad.length}/${results.length} checks clean.`, '',
    `Source data: ${coverage.chromium.jobs} jobs; ${coverage.chromium.alerts} alert; ${coverage.chromium.errors} error.`, '',
    '**Browserbase:** SKIP — localhost is not exposed to the cloud, and exposing it is outside the gate.', '',
    `- Chromium recording: ${coverage.chromium.videoPath}`, `- WebKit recording: ${coverage.webkit.videoPath}`, '',
    '| Engine | Control | Result | Detail |', '|---|---|---:|---|', ...results.map(x => `| ${x.engine} | ${x.label} | ${x.ok ? 'PASS' : 'FAIL'} | ${(x.errors.join('; ') || x.detail || '—').replace(/\|/g,'\\|')} |`), ''];
  fs.writeFileSync(path.join(ROOT, 'CTA-REPORT.md'), report.join('\n'));
  console.log(JSON.stringify({ verdict: bad.length ? 'FAIL' : 'PASS', checks: results.length, failures: bad.length, report: path.join(ROOT, 'CTA-REPORT.md') }));
  if (bad.length) process.exitCode = 1;
})().catch(e => { console.error(e); process.exit(1); });