← 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); });