288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { chromium } from 'playwright';
|
|
|
|
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
|
|
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const outputDir = path.join(webRoot, 'output', 'playwright');
|
|
const outputPath = path.join(outputDir, 'live-jobs-queues-action-sweep.json');
|
|
const authStatePath = path.join(outputDir, 'live-jobs-queues-action-sweep.state.json');
|
|
const authReportPath = path.join(outputDir, 'live-jobs-queues-action-sweep.auth.json');
|
|
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
|
const routePath = '/ops/operations/jobs-queues';
|
|
|
|
function scopedUrl(route = routePath) {
|
|
const separator = route.includes('?') ? '&' : '?';
|
|
return `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
|
}
|
|
|
|
async function settle(page) {
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
|
|
await page.waitForTimeout(1_200);
|
|
}
|
|
|
|
async function gotoPage(page) {
|
|
await page.goto(scopedUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await settle(page);
|
|
}
|
|
|
|
async function clickTab(page, tabName) {
|
|
await page.getByRole('button', { name: tabName }).click({ timeout: 10_000 });
|
|
await settle(page);
|
|
}
|
|
|
|
async function rowCount(page) {
|
|
return page.locator('tbody tr').count();
|
|
}
|
|
|
|
function searchFilter(page) {
|
|
return page.locator('section.filters input[type="search"]').first();
|
|
}
|
|
|
|
function filterSelect(page, index) {
|
|
return page.locator('section.filters select').nth(index);
|
|
}
|
|
|
|
function actionBanner(page) {
|
|
return page.locator('section.jobs-queues .jobs-queues__banner[role="status"]').first();
|
|
}
|
|
|
|
async function captureRuntimeIssues(page) {
|
|
const alerts = await page
|
|
.locator('[role="alert"], .error-banner, .toast, .notification')
|
|
.evaluateAll((nodes) =>
|
|
nodes
|
|
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
|
.filter(Boolean),
|
|
)
|
|
.catch(() => []);
|
|
|
|
return alerts;
|
|
}
|
|
|
|
async function readActionHrefs(page) {
|
|
return page.locator('tbody tr:first-child .actions a').evaluateAll((links) =>
|
|
links.map((link) => ({
|
|
label: (link.textContent || '').trim().replace(/\s+/g, ' '),
|
|
href: link.getAttribute('href') || '',
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function assert(condition, message, details = {}) {
|
|
if (!condition) {
|
|
throw new Error(`${message}${Object.keys(details).length ? ` ${JSON.stringify(details)}` : ''}`);
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
await mkdir(outputDir, { recursive: true });
|
|
|
|
const authReport = await authenticateFrontdoor({
|
|
statePath: authStatePath,
|
|
reportPath: authReportPath,
|
|
headless: true,
|
|
});
|
|
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--disable-dev-shm-usage'],
|
|
});
|
|
|
|
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
|
const page = await context.newPage();
|
|
const consoleErrors = [];
|
|
const responseErrors = [];
|
|
const requestFailures = [];
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
page.on('response', (response) => {
|
|
const url = response.url();
|
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
if (response.status() >= 400 && !url.includes('/connect/authorize')) {
|
|
responseErrors.push({
|
|
status: response.status(),
|
|
method: response.request().method(),
|
|
url,
|
|
});
|
|
}
|
|
});
|
|
|
|
page.on('requestfailed', (request) => {
|
|
const url = request.url();
|
|
const failure = request.failure()?.errorText ?? 'unknown';
|
|
if (failure === 'net::ERR_ABORTED') {
|
|
return;
|
|
}
|
|
|
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
requestFailures.push({
|
|
method: request.method(),
|
|
url,
|
|
error: failure,
|
|
});
|
|
});
|
|
|
|
const checks = [];
|
|
|
|
try {
|
|
await gotoPage(page);
|
|
await assert((await page.locator('h1').first().textContent())?.trim() === 'Jobs & Queues', 'Unexpected page heading.');
|
|
|
|
let actions = await readActionHrefs(page);
|
|
await assert(actions.length === 2, 'Jobs tab did not expose the expected two row actions.', { actions });
|
|
await assert(actions[0].href.includes('/ops/operations/jobengine'), 'Jobs primary action did not target JobEngine.', { actions });
|
|
await assert(actions[1].href.includes('/ops/operations/jobengine/jobs'), 'Jobs secondary action did not target the Job list.', { actions });
|
|
await assert(!actions.some((entry) => entry.href.includes('/ops/operations/jobs-queues')), 'Jobs tab still contains self-linking row actions.', { actions });
|
|
checks.push({ name: 'jobs-row-actions', ok: true, actions });
|
|
|
|
await searchFilter(page).fill('Vulnerability');
|
|
await settle(page);
|
|
let rows = await rowCount(page);
|
|
await assert(rows === 1, 'Jobs search filter did not reduce the table to one row.', { rows });
|
|
await page.getByRole('button', { name: 'Clear filters' }).click();
|
|
await settle(page);
|
|
rows = await rowCount(page);
|
|
await assert(rows === 4, 'Jobs clear filters did not restore all rows.', { rows });
|
|
checks.push({ name: 'jobs-filters', ok: true, rowsAfterClear: rows });
|
|
|
|
await searchFilter(page).fill('Vulnerability');
|
|
await settle(page);
|
|
await clickTab(page, 'Runs');
|
|
await assert(await searchFilter(page).inputValue() === '', 'Tab switch did not clear the search filter.');
|
|
checks.push({ name: 'tab-switch-clears-filters', ok: true });
|
|
|
|
await filterSelect(page, 0).selectOption('FAILED');
|
|
await filterSelect(page, 1).selectOption('DEGRADED');
|
|
await settle(page);
|
|
rows = await rowCount(page);
|
|
await assert(rows === 1, 'Runs filters did not isolate the failed degraded row.', { rows });
|
|
checks.push({ name: 'runs-filters', ok: true, rows });
|
|
|
|
await page.getByRole('button', { name: 'Clear filters' }).click();
|
|
await settle(page);
|
|
await page.getByRole('button', { name: 'Copy CorrID' }).first().click();
|
|
await settle(page);
|
|
const statusText = ((await actionBanner(page).textContent()) || '').trim();
|
|
await assert(statusText.includes('corr-run-001'), 'Runs copy action did not produce inline feedback.', { statusText });
|
|
checks.push({ name: 'runs-copy-feedback', ok: true, statusText });
|
|
|
|
await filterSelect(page, 0).selectOption('DEAD-LETTER');
|
|
await filterSelect(page, 1).selectOption('BLOCKING');
|
|
await settle(page);
|
|
await page.getByRole('link', { name: 'Open Dead-Letter Queue' }).click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/dead-letter/queue'), 'Runs dead-letter handoff did not reach the DLQ.');
|
|
checks.push({ name: 'runs-dead-letter-handoff', ok: true, targetUrl: page.url() });
|
|
|
|
await gotoPage(page);
|
|
await clickTab(page, 'Schedules');
|
|
await filterSelect(page, 0).selectOption('FAIL');
|
|
await filterSelect(page, 1).selectOption('Daily');
|
|
await settle(page);
|
|
rows = await rowCount(page);
|
|
await assert(rows === 1, 'Schedules filters did not isolate the failed daily schedule.', { rows });
|
|
await page.getByRole('link', { name: 'Review Dead-Letter Queue' }).click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/dead-letter/queue'), 'Schedules failure handoff did not reach the DLQ.');
|
|
checks.push({ name: 'schedules-failure-handoff', ok: true, targetUrl: page.url() });
|
|
|
|
await gotoPage(page);
|
|
await clickTab(page, 'Schedules');
|
|
await page.getByRole('link', { name: 'Manage Schedules' }).first().click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/scheduler/schedules'), 'Schedules primary handoff did not reach Scheduler.');
|
|
checks.push({ name: 'schedules-primary-handoff', ok: true, targetUrl: page.url() });
|
|
|
|
await gotoPage(page);
|
|
await clickTab(page, 'Dead Letters');
|
|
actions = await readActionHrefs(page);
|
|
await assert(actions[0].href.includes('/ops/operations/dead-letter/queue'), 'Dead-letter primary action did not target the queue.', { actions });
|
|
await assert(actions[1].href.includes('/ops/operations/data-integrity/dlq'), 'Dead-letter recovery action did not target the DLQ recovery surface.', { actions });
|
|
await page.getByRole('link', { name: 'Open Replay Recovery' }).first().click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/data-integrity/dlq'), 'Dead-letter recovery handoff did not reach Data Integrity.');
|
|
checks.push({ name: 'dead-letter-handoffs', ok: true, targetUrl: page.url() });
|
|
|
|
await gotoPage(page);
|
|
await clickTab(page, 'Workers');
|
|
await filterSelect(page, 0).selectOption('DEGRADED');
|
|
await filterSelect(page, 1).selectOption('feeds');
|
|
await settle(page);
|
|
rows = await rowCount(page);
|
|
await assert(rows === 1, 'Workers filters did not isolate the degraded feeds worker.', { rows });
|
|
await page.getByRole('link', { name: 'Open Worker Fleet' }).click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/scheduler/workers'), 'Workers primary handoff did not reach Worker Fleet.');
|
|
checks.push({ name: 'workers-primary-handoff', ok: true, targetUrl: page.url() });
|
|
|
|
await gotoPage(page);
|
|
await clickTab(page, 'Workers');
|
|
await filterSelect(page, 0).selectOption('DEGRADED');
|
|
await filterSelect(page, 1).selectOption('feeds');
|
|
await settle(page);
|
|
await page.getByRole('link', { name: 'Inspect Scheduler Runs' }).click();
|
|
await settle(page);
|
|
await assert(page.url().includes('/ops/operations/scheduler/runs'), 'Workers secondary handoff did not reach Scheduler Runs.');
|
|
checks.push({ name: 'workers-secondary-handoff', ok: true, targetUrl: page.url() });
|
|
|
|
const runtimeIssues = {
|
|
consoleErrors,
|
|
responseErrors,
|
|
requestFailures,
|
|
alerts: await captureRuntimeIssues(page),
|
|
};
|
|
const summary = {
|
|
checkedAtUtc: new Date().toISOString(),
|
|
routePath,
|
|
checks,
|
|
runtimeIssues,
|
|
failedCheckCount: checks.filter((check) => !check.ok).length,
|
|
runtimeIssueCount:
|
|
runtimeIssues.consoleErrors.length
|
|
+ runtimeIssues.responseErrors.length
|
|
+ runtimeIssues.requestFailures.length
|
|
+ runtimeIssues.alerts.length,
|
|
};
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
|
|
if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
} catch (error) {
|
|
const summary = {
|
|
checkedAtUtc: new Date().toISOString(),
|
|
routePath,
|
|
checks,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
consoleErrors,
|
|
responseErrors,
|
|
requestFailures,
|
|
};
|
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await context.close().catch(() => {});
|
|
await browser.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
await run();
|