Complete scratch iteration 004 setup and grouped route-action fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user