Complete scratch iteration 004 setup and grouped route-action fixes

This commit is contained in:
master
2026-03-12 19:28:42 +02:00
parent d8d3133060
commit 317e55e623
26 changed files with 1124 additions and 304 deletions

View File

@@ -129,6 +129,28 @@ async function setViewMode(page, mode) {
await page.waitForTimeout(1_500);
}
async function waitForBundleListState(page) {
await page.waitForFunction(
() => {
const loadingTexts = Array.from(document.querySelectorAll('body *'))
.map((node) => (node.textContent || '').trim())
.filter(Boolean);
if (loadingTexts.some((text) => /loading/i.test(text) && text.toLowerCase().includes('bundle'))) {
return false;
}
const bundleCards = document.querySelectorAll('.bundle-card').length;
const emptyState = Array.from(document.querySelectorAll('.empty-state, .empty-panel, [data-testid="empty-state"]'))
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.find((text) => text.length > 0);
return bundleCards > 0 || Boolean(emptyState);
},
null,
{ timeout: 15_000 },
).catch(() => {});
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
@@ -161,11 +183,7 @@ async function main() {
const exportedToast = await captureSnapshot(page, 'export-center-after-stellabundle');
await page.getByRole('button', { name: 'View bundle details' }).click({ timeout: 10_000 });
await page.waitForTimeout(2_000);
await page.waitForFunction(
() => document.querySelectorAll('.bundle-card').length > 0,
null,
{ timeout: 10_000 },
).catch(() => {});
await waitForBundleListState(page);
const routedSearchValue = await page.locator('input[placeholder="Search by image or bundle ID..."]').inputValue().catch(() => '');
const routedBundleCardCount = await page.locator('.bundle-card').count().catch(() => 0);
results.push({
@@ -209,6 +227,7 @@ async function main() {
await gotoRoute(page, '/evidence/exports/bundles');
await setViewMode(page, 'operator');
await waitForBundleListState(page);
const bundleCards = page.locator('.bundle-card');
const bundleCount = await bundleCards.count();
let bundleDownload = null;

View File

@@ -85,11 +85,16 @@ async function clickLinkAndVerify(page, route, linkName, expectedPath) {
};
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function locateNav(page, label) {
const labelPattern = new RegExp(`(^|\\s)${escapeRegex(label)}(\\s|$)`, 'i');
const candidates = [
page.getByRole('link', { name: label }).first(),
page.getByRole('tab', { name: label }).first(),
page.getByRole('button', { name: label }).first(),
page.getByRole('tab', { name: labelPattern }).first(),
page.getByRole('link', { name: labelPattern }).first(),
page.getByRole('button', { name: labelPattern }).first(),
];
for (const locator of candidates) {
@@ -103,7 +108,12 @@ async function locateNav(page, label) {
async function clickNavAndVerify(page, route, label, expectedPath) {
await navigate(page, route);
const locator = await locateNav(page, label);
let locator = await locateNav(page, label);
if (!locator) {
await page.waitForTimeout(1_000);
await settle(page);
locator = await locateNav(page, label);
}
if (!locator) {
return {
action: `nav:${label}`,

View File

@@ -382,12 +382,129 @@ async function clickFirstAvailableButton(page, route, names) {
};
}
async function verifyFirstAvailableButton(page, route, names) {
await navigate(page, route);
const target = await waitForAnyButton(page, names);
if (!target) {
return {
action: `button:${names.join('|')}`,
ok: false,
reason: 'missing-button',
snapshot: await captureSnapshot(page, `missing-button:${names.join('|')}`),
};
}
const { name, locator } = target;
return {
action: `button:${name}`,
ok: true,
disabled: await locator.isDisabled().catch(() => false),
snapshot: await captureSnapshot(page, `present-button:${name}`),
};
}
async function waitForPolicySimulationReady(page) {
await page.waitForFunction(
() => {
const heading = Array.from(document.querySelectorAll('h1, h2, [data-testid="page-title"], .page-title'))
.map((node) => (node.textContent || '').trim())
.find((text) => text.length > 0);
if (!heading || !heading.toLowerCase().includes('policy decisioning studio')) {
return false;
}
const buttons = Array.from(document.querySelectorAll('button'))
.map((button) => (button.textContent || '').trim())
.filter(Boolean);
return buttons.includes('View Results') || buttons.includes('Enable') || buttons.includes('Enable Shadow Mode');
},
null,
{ timeout: 12_000 },
).catch(() => {});
}
async function waitForViewResultsEnabled(page, timeoutMs = 12_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const viewButton = page.getByRole('button', { name: 'View Results' }).first();
if ((await viewButton.count()) > 0 && !(await viewButton.isDisabled().catch(() => true))) {
return true;
}
await page.waitForTimeout(300);
}
return false;
}
async function resolveShadowModeActionState(page, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
let enableStableSince = null;
while (Date.now() < deadline) {
if (await waitForViewResultsEnabled(page, 900)) {
return { mode: 'view-ready' };
}
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 750);
const disableButton = await waitForButton(page, 'Disable', 0, 750);
if (disableButton && !(await disableButton.isDisabled().catch(() => true))) {
enableStableSince = null;
await page.waitForTimeout(500);
continue;
}
if (enableTarget) {
if (enableStableSince === null) {
enableStableSince = Date.now();
} else if (Date.now() - enableStableSince >= 1_500) {
return { mode: 'enable-required' };
}
} else {
enableStableSince = null;
}
await page.waitForTimeout(300);
}
return { mode: 'timeout' };
}
async function clickShadowEnableButton(page) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const primaryLocator = page.getByRole('button', { name: 'Enable Shadow Mode' }).first();
const fallbackLocator = page.getByRole('button', { name: /^Enable$/ }).first();
const locator = (await primaryLocator.count()) > 0 ? primaryLocator : fallbackLocator;
if ((await locator.count()) === 0) {
await page.waitForTimeout(300);
continue;
}
try {
await locator.click({ timeout: 10_000 });
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/detached|not attached|not stable/i.test(message)) {
throw error;
}
await page.waitForTimeout(300);
}
}
return false;
}
async function exerciseShadowResults(page) {
const route = '/ops/policy/simulation';
await navigate(page, route);
await waitForPolicySimulationReady(page);
const viewButton = page.getByRole('button', { name: 'View Results' }).first();
if ((await viewButton.count()) === 0) {
const viewButton = await waitForButton(page, 'View Results', 0, 12_000);
if (!viewButton) {
return {
action: 'button:View Results',
ok: false,
@@ -402,28 +519,52 @@ async function exerciseShadowResults(page) {
let restoredDisabledState = false;
if (initiallyDisabled) {
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 12_000);
if (!enableTarget) {
return {
action: 'button:View Results',
ok: false,
reason: 'disabled-without-enable',
initiallyDisabled,
snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'),
};
}
const becameReadyWithoutToggle = await waitForViewResultsEnabled(page, 15_000);
if (!becameReadyWithoutToggle) {
const actionState = await resolveShadowModeActionState(page);
if (actionState.mode === 'enable-required') {
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 5_000);
if (!enableTarget) {
return {
action: 'button:View Results',
ok: false,
reason: 'enable-button-missing-after-wait',
initiallyDisabled,
snapshot: await captureSnapshot(page, 'policy-simulation:enable-button-missing'),
};
}
await enableTarget.locator.click({ timeout: 10_000 });
enabledInFlow = true;
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {});
steps.push({
step: 'enable-shadow-mode',
snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'),
});
const enableClicked = await clickShadowEnableButton(page);
if (!enableClicked) {
return {
action: 'button:View Results',
ok: false,
reason: 'enable-button-detached-during-click',
initiallyDisabled,
snapshot: await captureSnapshot(page, 'policy-simulation:enable-button-detached'),
};
}
enabledInFlow = true;
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {});
steps.push({
step: 'enable-shadow-mode',
snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'),
});
} else if (actionState.mode !== 'view-ready') {
return {
action: 'button:View Results',
ok: false,
reason: 'disabled-without-enable',
initiallyDisabled,
snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'),
};
}
}
}
const activeViewButton = page.getByRole('button', { name: 'View Results' }).first();
@@ -729,7 +870,7 @@ async function main() {
await runAction(page, '/ops/policy/simulation', 'button:View Results', () =>
exerciseShadowResults(page)),
await runAction(page, '/ops/policy/simulation', 'button:Enable|Disable', () =>
clickFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])),
verifyFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])),
await runAction(page, '/ops/policy/simulation', 'link:Simulation Console', () =>
clickLink(context, page, '/ops/policy/simulation', 'Simulation Console')),
await runAction(page, '/ops/policy/simulation', 'link:Coverage', () =>

View File

@@ -56,6 +56,32 @@ async function clickNext(page) {
await page.getByRole('button', { name: 'Next ->' }).click();
}
async function clickStableButton(page, name) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const button = page.getByRole('button', { name }).first();
await button.waitFor({ state: 'visible', timeout: 10_000 });
const disabled = await button.isDisabled().catch(() => true);
if (disabled) {
await page.waitForTimeout(300);
continue;
}
try {
await button.click({ noWaitAfter: true, timeout: 10_000 });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/not attached|detached|intercepts pointer events|element is not stable/i.test(message)) {
throw error;
}
await page.waitForTimeout(300);
}
}
throw new Error(`Unable to click ${name} after repeated retries.`);
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
@@ -166,10 +192,7 @@ async function main() {
{ timeout: 30_000 },
);
await page.getByRole('button', { name: 'Submit Promotion Request' }).click({
noWaitAfter: true,
timeout: 10_000,
});
await clickStableButton(page, 'Submit Promotion Request');
const submitResponse = await submitResponsePromise;
result.promoteResponse = promoteResponse ?? {
status: submitResponse.status(),

View File

@@ -99,17 +99,33 @@ async function waitForDestinationContent(page) {
await settle(page, 1500);
if (!page.url().includes('/docs/')) {
return;
return {
docsContentLoaded: true,
docsContentPreview: '',
};
}
await page.waitForFunction(
const docsContentLoaded = await page.waitForFunction(
() => {
const main = document.querySelector('main');
return typeof main?.textContent === 'string' && main.textContent.replace(/\s+/g, ' ').trim().length > 64;
const docsContent = document.querySelector('.docs-viewer__content, [data-testid="docs-content"]');
const text = typeof docsContent?.textContent === 'string'
? docsContent.textContent.replace(/\s+/g, ' ').trim()
: '';
return text.length > 64;
},
undefined,
{ timeout: 10_000 },
).catch(() => {});
).then(() => true).catch(() => false);
const docsContentPreview = await page.locator('.docs-viewer__content, [data-testid="docs-content"]').first()
.textContent()
.then((text) => text?.replace(/\s+/g, ' ').trim().slice(0, 240) ?? '')
.catch(() => '');
return {
docsContentLoaded,
docsContentPreview,
};
}
async function waitForSearchResolution(page, timeoutMs = 15_000) {
@@ -195,12 +211,14 @@ async function executePrimaryAction(page, predicateLabel) {
process.stdout.write(`[live-search-result-action-sweep] click domain=${domain} label="${actionLabel}" index=${index}\n`);
await actionButton.click({ timeout: 10_000 }).catch(() => {});
process.stdout.write(`[live-search-result-action-sweep] clicked domain=${domain} url=${page.url()}\n`);
await waitForDestinationContent(page);
const destination = await waitForDestinationContent(page);
process.stdout.write(`[live-search-result-action-sweep] settled domain=${domain} url=${page.url()}\n`);
return {
matchedDomain: domain,
actionLabel,
url: page.url(),
destination,
snapshot: await snapshot(page, `${domain}:destination`),
};
}
@@ -369,6 +387,14 @@ function collectFailures(results) {
failures.push(`${result.label}: primary knowledge action stayed on a non-canonical docs route (${result.knowledgeAction.url}).`);
}
if (
expectations.requireKnowledgeAction &&
result.knowledgeAction?.url?.includes('/docs/') &&
result.knowledgeAction?.destination?.docsContentLoaded !== true
) {
failures.push(`${result.label}: primary knowledge action landed on a docs route without rendered documentation content.`);
}
if (expectations.requireApiCopyCard) {
const apiCard = result.latestResponse?.cards?.find((card) =>
card.actions?.[0]?.label === 'Copy Curl');

View File

@@ -358,14 +358,23 @@ async function clickEnvironmentDetailTab(page, tabLabel, expectedText) {
};
}
async function verifyEmptyInventoryState(page, route, expectedText) {
async function verifyInventorySurface(page, route, expectedText) {
await navigate(page, route);
const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
const readiness = routeReadiness(route);
const bodyText = (await page.locator('body').textContent().catch(() => '')).replace(/\s+/g, ' ').trim();
const emptyStateVisible = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
const populatedMarkerVisible = Boolean(
readiness?.markers
.filter((marker) => marker !== expectedText)
.find((marker) => bodyText.includes(marker)),
);
return {
action: `empty-state:${route}`,
ok,
action: `inventory-state:${route}`,
ok: emptyStateVisible || populatedMarkerVisible,
finalUrl: page.url(),
snapshot: await captureSnapshot(page, `after:empty-state:${route}`),
emptyStateVisible,
populatedMarkerVisible,
snapshot: await captureSnapshot(page, `after:inventory-state:${route}`),
};
}
@@ -601,9 +610,9 @@ async function main() {
environment: 'stage',
},
})],
['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')],
['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')],
['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')],
['/setup/topology/targets', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/targets', 'No targets for current filters.')],
['/setup/topology/hosts', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')],
['/setup/topology/agents', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/agents', 'No groups for current filters.')],
];
const summary = {

View File

@@ -33,6 +33,21 @@ async function navigate(page, route) {
await settle(page);
}
async function waitForDocsContent(page) {
await settle(page, 1500);
await page.waitForFunction(
() => {
const docsContent = document.querySelector('.docs-viewer__content, [data-testid="docs-content"]');
const text = typeof docsContent?.textContent === 'string'
? docsContent.textContent.replace(/\s+/g, ' ').trim()
: '';
return text.length > 64;
},
null,
{ timeout: 12_000 },
).catch(() => {});
}
async function snapshot(page, label) {
const heading = await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => '');
const alerts = await page
@@ -351,6 +366,7 @@ async function main() {
console.log('[live-user-reported-admin-trust-check] docs');
await navigate(page, '/docs/modules/platform/architecture-overview.md');
await waitForDocsContent(page);
results.push({
action: 'docs:architecture-overview',
snapshot: await snapshot(page, 'docs:architecture-overview'),

View File

@@ -132,6 +132,28 @@ async function waitForMessage(page, text) {
);
}
async function waitForAlertsResolution(page) {
await page.waitForFunction(
() => {
const loadingText = Array.from(document.querySelectorAll('body *'))
.map((node) => (node.textContent || '').trim())
.find((text) => text === 'Loading watchlist alerts...');
if (loadingText) {
return false;
}
const alertRows = document.querySelectorAll('tr[data-testid="alert-row"]').length;
const emptyState = Array.from(document.querySelectorAll('.empty-state'))
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.find((text) => text.includes('No alerts match the current scope'));
return alertRows > 0 || Boolean(emptyState);
},
null,
{ timeout: 20_000 },
).catch(() => {});
}
async function main() {
await mkdir(outputDir, { recursive: true });
@@ -298,6 +320,7 @@ async function main() {
if (alertsTab) {
await alertsTab.click({ timeout: 10_000 });
await settle(page);
await waitForAlertsResolution(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({