371 lines
12 KiB
JavaScript
371 lines
12 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const webRoot = path.resolve(__dirname, '..');
|
|
const outputDir = path.join(webRoot, 'output', 'playwright');
|
|
const outputPath = path.join(outputDir, 'live-watchlist-action-sweep.json');
|
|
const authStatePath = path.join(outputDir, 'live-watchlist-action-sweep.state.json');
|
|
const authReportPath = path.join(outputDir, 'live-watchlist-action-sweep.auth.json');
|
|
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
|
|
|
function uniqueSuffix() {
|
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
}
|
|
|
|
async function settle(page, timeout = 1_500) {
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
|
await page.waitForTimeout(timeout);
|
|
}
|
|
|
|
async function headingText(page) {
|
|
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
|
|
const count = await headings.count();
|
|
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
|
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
async function captureSnapshot(page, label) {
|
|
return {
|
|
label,
|
|
url: page.url(),
|
|
title: await page.title(),
|
|
heading: await headingText(page),
|
|
message: (await page.locator('.message-banner').textContent().catch(() => '')).trim(),
|
|
};
|
|
}
|
|
|
|
async function navigate(page, route) {
|
|
const separator = route.includes('?') ? '&' : '?';
|
|
const url = `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await settle(page);
|
|
return url;
|
|
}
|
|
|
|
async function findNav(page, label) {
|
|
const candidates = [
|
|
page.getByRole('tab', { name: label }).first(),
|
|
page.getByRole('button', { name: label }).first(),
|
|
page.getByRole('link', { name: label }).first(),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if ((await candidate.count()) > 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function openEntriesTab(page) {
|
|
const locator = await findNav(page, 'Entries');
|
|
if (locator) {
|
|
await locator.click({ timeout: 10_000 });
|
|
await settle(page);
|
|
}
|
|
}
|
|
|
|
async function waitForSubmitEnabled(page, label) {
|
|
await page.waitForFunction(
|
|
(buttonLabel) => {
|
|
const button = Array.from(document.querySelectorAll('button'))
|
|
.find((candidate) => (candidate.textContent || '').replace(/\s+/g, ' ').trim() === buttonLabel);
|
|
return !!button && !button.disabled;
|
|
},
|
|
label,
|
|
{ timeout: 20_000 },
|
|
);
|
|
}
|
|
|
|
async function rowLocator(page, text) {
|
|
return page.locator('tr[data-testid="entry-row"]').filter({ hasText: text }).first();
|
|
}
|
|
|
|
async function waitForRow(page, text) {
|
|
const row = await rowLocator(page, text);
|
|
await row.waitFor({ state: 'visible', timeout: 20_000 });
|
|
return row;
|
|
}
|
|
|
|
async function fillEntryForm(page, values) {
|
|
const form = page.locator('form[data-testid="entry-form"]');
|
|
if (values.displayName !== undefined) {
|
|
await form.locator('input[formcontrolname="displayName"]').fill(values.displayName);
|
|
}
|
|
if (values.issuer !== undefined) {
|
|
await form.locator('input[formcontrolname="issuer"]').fill(values.issuer);
|
|
}
|
|
if (values.keyId !== undefined) {
|
|
await form.locator('input[formcontrolname="keyId"]').fill(values.keyId);
|
|
}
|
|
}
|
|
|
|
async function clickEntryAction(page, rowText, actionLabel) {
|
|
const row = await waitForRow(page, rowText);
|
|
await row.getByRole('button', { name: actionLabel }).click({ timeout: 10_000 });
|
|
await settle(page);
|
|
}
|
|
|
|
async function waitForMessage(page, text) {
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const banner = document.querySelector('.message-banner');
|
|
return !!banner && (banner.textContent || '').includes(expected);
|
|
},
|
|
text,
|
|
{ timeout: 20_000 },
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
await mkdir(outputDir, { recursive: true });
|
|
|
|
let currentAction = 'authenticate-frontdoor';
|
|
let browser;
|
|
let context;
|
|
const runtime = {
|
|
consoleErrors: [],
|
|
pageErrors: [],
|
|
responseErrors: [],
|
|
requestFailures: [],
|
|
};
|
|
|
|
const suffix = uniqueSuffix();
|
|
const createdName = `QA Watchlist ${suffix}`;
|
|
const updatedName = `${createdName} Updated`;
|
|
const duplicateName = `${createdName} Duplicate`;
|
|
const issuer = `https://${suffix}.example.test`;
|
|
const keyId = `kid-${suffix}`;
|
|
const results = [];
|
|
let page;
|
|
|
|
try {
|
|
const authReport = await authenticateFrontdoor({
|
|
statePath: authStatePath,
|
|
reportPath: authReportPath,
|
|
headless: true,
|
|
});
|
|
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--disable-dev-shm-usage'],
|
|
});
|
|
|
|
context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
|
page = await context.newPage();
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
runtime.pageErrors.push({ page: page.url(), message: error.message });
|
|
});
|
|
page.on('requestfailed', (request) => {
|
|
const url = request.url();
|
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
runtime.requestFailures.push({
|
|
page: page.url(),
|
|
method: request.method(),
|
|
url,
|
|
error: request.failure()?.errorText ?? 'unknown',
|
|
});
|
|
});
|
|
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) {
|
|
runtime.responseErrors.push({
|
|
page: page.url(),
|
|
method: response.request().method(),
|
|
status: response.status(),
|
|
url,
|
|
});
|
|
}
|
|
});
|
|
page.on('dialog', (dialog) => dialog.accept().catch(() => undefined));
|
|
|
|
currentAction = 'route:/setup/trust-signing/watchlist/entries';
|
|
await navigate(page, '/setup/trust-signing/watchlist/entries');
|
|
results.push({
|
|
action: 'route:/setup/trust-signing/watchlist/entries',
|
|
ok: (await headingText(page)).length > 0 && (await page.getByTestId('watchlist-page').count()) > 0,
|
|
snapshot: await captureSnapshot(page, 'watchlist-entries'),
|
|
});
|
|
|
|
currentAction = 'create-entry';
|
|
await page.getByTestId('create-entry-btn').click({ timeout: 10_000 });
|
|
await settle(page);
|
|
await fillEntryForm(page, { displayName: createdName, issuer, keyId });
|
|
await waitForSubmitEnabled(page, 'Create rule');
|
|
await page.getByRole('button', { name: 'Create rule' }).click({ timeout: 10_000 });
|
|
await waitForMessage(page, 'Watchlist entry created.');
|
|
await waitForRow(page, createdName);
|
|
results.push({
|
|
action: 'create-entry',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-create'),
|
|
});
|
|
|
|
currentAction = 'edit-entry';
|
|
await clickEntryAction(page, createdName, 'Edit');
|
|
await fillEntryForm(page, { displayName: updatedName, issuer, keyId });
|
|
await waitForSubmitEnabled(page, 'Save changes');
|
|
await page.getByRole('button', { name: 'Save changes' }).click({ timeout: 10_000 });
|
|
await waitForMessage(page, 'Watchlist entry updated.');
|
|
await waitForRow(page, updatedName);
|
|
results.push({
|
|
action: 'edit-entry',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-edit'),
|
|
});
|
|
|
|
currentAction = 'test-pattern';
|
|
await clickEntryAction(page, updatedName, 'Edit');
|
|
const testSection = page.locator('.test-section');
|
|
await testSection.locator('input[formcontrolname="issuer"]').fill(issuer);
|
|
await testSection.locator('input[formcontrolname="keyId"]').fill(keyId);
|
|
await page.getByRole('button', { name: 'Run test' }).click({ timeout: 10_000 });
|
|
await page.getByTestId('test-result').waitFor({ state: 'visible', timeout: 20_000 });
|
|
const testText = (await page.getByTestId('test-result').textContent().catch(() => '')).trim();
|
|
results.push({
|
|
action: 'test-pattern',
|
|
ok: testText.includes('Match'),
|
|
snapshot: await captureSnapshot(page, 'after-test-pattern'),
|
|
detail: testText,
|
|
});
|
|
await page.getByRole('button', { name: 'Close' }).click({ timeout: 10_000 });
|
|
await settle(page);
|
|
|
|
currentAction = 'duplicate-entry';
|
|
await clickEntryAction(page, updatedName, 'Duplicate');
|
|
await fillEntryForm(page, { displayName: duplicateName, issuer, keyId });
|
|
await waitForSubmitEnabled(page, 'Create duplicate');
|
|
await page.getByRole('button', { name: 'Create duplicate' }).click({ timeout: 10_000 });
|
|
await waitForMessage(page, 'Watchlist entry duplicated.');
|
|
await waitForRow(page, duplicateName);
|
|
results.push({
|
|
action: 'duplicate-entry',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-duplicate'),
|
|
});
|
|
|
|
currentAction = 'save-tuning';
|
|
await clickEntryAction(page, updatedName, 'Tune');
|
|
const tuningForm = page.locator('form[data-testid="tuning-form"]');
|
|
await tuningForm.locator('input[formcontrolname="suppressDuplicatesMinutes"]').fill('15');
|
|
await tuningForm.locator('textarea[formcontrolname="channelOverridesText"]').fill('slack:security');
|
|
await waitForSubmitEnabled(page, 'Save tuning');
|
|
await page.getByRole('button', { name: 'Save tuning' }).click({ timeout: 10_000 });
|
|
await page.waitForFunction(
|
|
() => {
|
|
const banner = document.querySelector('.message-banner');
|
|
return !!banner && (banner.textContent || '').includes('Saved tuning for');
|
|
},
|
|
null,
|
|
{ timeout: 20_000 },
|
|
);
|
|
results.push({
|
|
action: 'save-tuning',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-save-tuning'),
|
|
});
|
|
|
|
currentAction = 'alerts-tab';
|
|
const alertsTab = await findNav(page, 'Alerts');
|
|
if (alertsTab) {
|
|
await alertsTab.click({ timeout: 10_000 });
|
|
await settle(page);
|
|
const alertRows = await page.locator('tr[data-testid="alert-row"]').count();
|
|
const emptyState = (await page.locator('.empty-state').first().textContent().catch(() => '')).trim();
|
|
results.push({
|
|
action: 'alerts-tab',
|
|
ok: alertRows > 0 || emptyState.includes('No alerts match the current scope'),
|
|
snapshot: await captureSnapshot(page, 'alerts-tab'),
|
|
detail: alertRows > 0 ? `${alertRows} alert rows` : emptyState,
|
|
});
|
|
}
|
|
|
|
currentAction = 'delete-duplicate';
|
|
await openEntriesTab(page);
|
|
await clickEntryAction(page, duplicateName, 'Delete');
|
|
await waitForMessage(page, 'Watchlist entry deleted.');
|
|
await page.waitForFunction(
|
|
(name) => !Array.from(document.querySelectorAll('tr[data-testid="entry-row"]'))
|
|
.some((row) => (row.textContent || '').includes(name)),
|
|
duplicateName,
|
|
{ timeout: 20_000 },
|
|
);
|
|
results.push({
|
|
action: 'delete-duplicate',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-delete-duplicate'),
|
|
});
|
|
|
|
currentAction = 'delete-original';
|
|
await clickEntryAction(page, updatedName, 'Delete');
|
|
await waitForMessage(page, 'Watchlist entry deleted.');
|
|
await page.waitForFunction(
|
|
(name) => !Array.from(document.querySelectorAll('tr[data-testid="entry-row"]'))
|
|
.some((row) => (row.textContent || '').includes(name)),
|
|
updatedName,
|
|
{ timeout: 20_000 },
|
|
);
|
|
results.push({
|
|
action: 'delete-original',
|
|
ok: true,
|
|
snapshot: await captureSnapshot(page, 'after-delete-original'),
|
|
});
|
|
|
|
const summary = {
|
|
generatedAtUtc: new Date().toISOString(),
|
|
results,
|
|
runtime,
|
|
};
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
} catch (error) {
|
|
const summary = {
|
|
generatedAtUtc: new Date().toISOString(),
|
|
failedAction: currentAction,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
results,
|
|
runtime,
|
|
finalUrl: page?.url() ?? null,
|
|
};
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
process.stderr.write(`[live-watchlist-action-sweep] ${summary.error}\n`);
|
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await context?.close().catch(() => undefined);
|
|
await browser?.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
main();
|