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({
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, RouterLink, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { NotificationDashboardComponent } from './notification-dashboard.component';
|
||||
import { NOTIFIER_API } from '../../../core/api/notifier.client';
|
||||
@@ -15,7 +16,6 @@ describe('NotificationDashboardComponent', () => {
|
||||
let component: NotificationDashboardComponent;
|
||||
let fixture: ComponentFixture<NotificationDashboardComponent>;
|
||||
let mockApi: jasmine.SpyObj<any>;
|
||||
let mockRouter: jasmine.SpyObj<Router>;
|
||||
|
||||
const mockRules: NotifierRule[] = [
|
||||
{
|
||||
@@ -56,7 +56,7 @@ describe('NotificationDashboardComponent', () => {
|
||||
name: 'email-ops',
|
||||
type: 'Email',
|
||||
enabled: true,
|
||||
config: { toAddresses: ['ops@example.com'] },
|
||||
config: { toAddresses: ['ops@example.com'], fromAddress: 'stella@example.com' },
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -79,7 +79,6 @@ describe('NotificationDashboardComponent', () => {
|
||||
'listChannels',
|
||||
'getDeliveryStats',
|
||||
]);
|
||||
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
|
||||
|
||||
mockApi.listRules.and.returnValue(of({ items: mockRules, total: 2 }));
|
||||
mockApi.listChannels.and.returnValue(of({ items: mockChannels, total: 2 }));
|
||||
@@ -96,8 +95,8 @@ describe('NotificationDashboardComponent', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotificationDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: NOTIFIER_API, useValue: mockApi },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -286,6 +285,23 @@ describe('NotificationDashboardComponent', () => {
|
||||
expect(compiled.querySelector('.tab-navigation')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides decorative tab icons from accessible names', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const icons = Array.from(compiled.querySelectorAll('.tab-navigation .tab-icon'));
|
||||
|
||||
expect(icons.length).toBe(component.tabs.length);
|
||||
expect(icons.every((icon) => icon.getAttribute('aria-hidden') === 'true')).toBeTrue();
|
||||
});
|
||||
|
||||
it('labels top-level tabs with their plain text names', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const tabButtons = Array.from(compiled.querySelectorAll('.tab-navigation .tab-button'));
|
||||
|
||||
expect(tabButtons.map((tab) => tab.getAttribute('aria-label'))).toEqual(
|
||||
component.tabs.map((tab) => tab.label),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display stat cards with values', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.textContent).toContain('150'); // totalSent
|
||||
@@ -300,6 +316,18 @@ describe('NotificationDashboardComponent', () => {
|
||||
expect(compiled.querySelector('.sub-navigation')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves query params through top and config sub-navigation', () => {
|
||||
component.setActiveTab('config');
|
||||
fixture.detectChanges();
|
||||
|
||||
const navLinks = fixture.debugElement
|
||||
.queryAll(By.directive(RouterLink))
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink));
|
||||
|
||||
expect(navLinks.length).toBe(10);
|
||||
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should display sub-navigation when delivery tab is active', () => {
|
||||
component.setActiveTab('delivery');
|
||||
fixture.detectChanges();
|
||||
@@ -308,6 +336,18 @@ describe('NotificationDashboardComponent', () => {
|
||||
expect(compiled.querySelector('.sub-navigation')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves query params through delivery sub-navigation', () => {
|
||||
component.setActiveTab('delivery');
|
||||
fixture.detectChanges();
|
||||
|
||||
const navLinks = fixture.debugElement
|
||||
.queryAll(By.directive(RouterLink))
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink));
|
||||
|
||||
expect(navLinks.length).toBe(8);
|
||||
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should display error banner when error exists', () => {
|
||||
component['error'].set('Test error message');
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -110,11 +110,13 @@ interface ConfigSubTab {
|
||||
[class.active]="activeTab() === tab.id"
|
||||
[attr.aria-selected]="activeTab() === tab.id"
|
||||
[attr.aria-controls]="'panel-' + tab.id"
|
||||
[attr.aria-label]="tab.label"
|
||||
role="tab"
|
||||
[routerLink]="tab.route"
|
||||
queryParamsHandling="merge"
|
||||
routerLinkActive="active"
|
||||
(click)="setActiveTab(tab.id)">
|
||||
<span class="tab-icon">{{ tab.icon }}</span>
|
||||
<span class="tab-icon" aria-hidden="true">{{ tab.icon }}</span>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
</a>
|
||||
}
|
||||
@@ -127,6 +129,8 @@ interface ConfigSubTab {
|
||||
<a
|
||||
class="sub-tab-button"
|
||||
[routerLink]="subTab.route"
|
||||
[attr.aria-label]="subTab.label"
|
||||
queryParamsHandling="merge"
|
||||
routerLinkActive="active">
|
||||
{{ subTab.label }}
|
||||
</a>
|
||||
@@ -137,8 +141,8 @@ interface ConfigSubTab {
|
||||
<!-- Delivery Sub-Navigation -->
|
||||
@if (activeTab() === 'delivery') {
|
||||
<nav class="sub-navigation" role="tablist">
|
||||
<a class="sub-tab-button" routerLink="delivery" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
|
||||
<a class="sub-tab-button" routerLink="delivery/analytics" routerLinkActive="active">Analytics</a>
|
||||
<a class="sub-tab-button" routerLink="delivery" queryParamsHandling="merge" aria-label="History" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
|
||||
<a class="sub-tab-button" routerLink="delivery/analytics" queryParamsHandling="merge" aria-label="Analytics" routerLinkActive="active">Analytics</a>
|
||||
</nav>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { renderMarkdownDocument } from './docs-markdown';
|
||||
|
||||
describe('renderMarkdownDocument', () => {
|
||||
function resolveDocsLink(target: string): string | null {
|
||||
return target.startsWith('http') ? target : `/docs/${target.replace(/^\.\//, '')}`;
|
||||
}
|
||||
|
||||
it('renders malformed inline code fences without stalling the document parser', () => {
|
||||
const markdown = [
|
||||
'# AdvisoryAI chat',
|
||||
'',
|
||||
'```n User: What is the status of CVE-2023-44487?',
|
||||
'',
|
||||
'Assistant: It is reachable in the current environment.',
|
||||
'```n',
|
||||
'',
|
||||
'Follow-up paragraph.',
|
||||
].join('\n');
|
||||
|
||||
const rendered = renderMarkdownDocument(markdown, 'modules/advisory-ai/chat-interface.md', resolveDocsLink);
|
||||
|
||||
expect(rendered.title).toBe('AdvisoryAI chat');
|
||||
expect(rendered.headings.map((heading) => heading.text)).toEqual(['AdvisoryAI chat']);
|
||||
expect(rendered.html).toContain('User: What is the status of CVE-2023-44487?');
|
||||
expect(rendered.html).toContain('Assistant: It is reachable in the current environment.');
|
||||
expect(rendered.html).toContain('<p>Follow-up paragraph.</p>');
|
||||
});
|
||||
|
||||
it('preserves valid fenced code blocks and inline links', () => {
|
||||
const markdown = [
|
||||
'## Example',
|
||||
'',
|
||||
'Visit [the docs](./README.md).',
|
||||
'',
|
||||
'```yaml',
|
||||
'enabled: true',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const rendered = renderMarkdownDocument(markdown, 'README.md', resolveDocsLink);
|
||||
|
||||
expect(rendered.headings[0]).toEqual({ level: 2, text: 'Example', id: 'example' });
|
||||
expect(rendered.html).toContain('href="/docs/README.md"');
|
||||
expect(rendered.html).toContain('language-yaml');
|
||||
expect(rendered.html).toContain('enabled: true');
|
||||
});
|
||||
});
|
||||
220
src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts
Normal file
220
src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
export interface DocsHeading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
title: string;
|
||||
headings: DocsHeading[];
|
||||
html: string;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function slugifyHeading(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(value: string, currentDocPath: string, resolveDocsLink: (target: string, currentDocPath: string) => string | null): string {
|
||||
let rendered = escapeHtml(value);
|
||||
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => {
|
||||
const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#';
|
||||
const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref);
|
||||
const attributes = isExternal ? ' target="_blank" rel="noopener"' : '';
|
||||
return `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
|
||||
});
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function isCodeFence(line: string): boolean {
|
||||
return /^```/.test(line.trim());
|
||||
}
|
||||
|
||||
function parseFenceStart(line: string): { language: string; inlineContent: string } {
|
||||
const fenceContent = line.trim().slice(3).trimStart();
|
||||
if (!fenceContent) {
|
||||
return { language: '', inlineContent: '' };
|
||||
}
|
||||
|
||||
const match = fenceContent.match(/^([^\s]+)(?:\s+(.*))?$/);
|
||||
if (!match) {
|
||||
return { language: '', inlineContent: fenceContent };
|
||||
}
|
||||
|
||||
const [, token, remainder = ''] = match;
|
||||
const normalizedToken = token.toLowerCase();
|
||||
|
||||
if (normalizedToken === 'n' && remainder.trim().length > 0) {
|
||||
return { language: '', inlineContent: remainder.trim() };
|
||||
}
|
||||
|
||||
if (['text', 'txt', 'json', 'yaml', 'yml', 'bash', 'sh', 'shell', 'powershell', 'ps1', 'http', 'sql', 'xml', 'html', 'css', 'ts', 'tsx', 'js', 'jsx', 'csharp', 'cs', 'md', 'markdown'].includes(normalizedToken)) {
|
||||
return { language: token, inlineContent: remainder.trim() };
|
||||
}
|
||||
|
||||
return { language: '', inlineContent: fenceContent };
|
||||
}
|
||||
|
||||
function pushParagraph(parts: string[], paragraphLines: string[], currentDocPath: string, resolveDocsLink: (target: string, currentDocPath: string) => string | null): void {
|
||||
if (paragraphLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
parts.push(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath, resolveDocsLink)}</p>`);
|
||||
}
|
||||
|
||||
export function renderMarkdownDocument(
|
||||
markdown: string,
|
||||
currentDocPath: string,
|
||||
resolveDocsLink: (target: string, currentDocPath: string) => string | null,
|
||||
): RenderedDocument {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const parts: string[] = [];
|
||||
const headings: DocsHeading[] = [];
|
||||
let title = 'Documentation';
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCodeFence(line)) {
|
||||
const { language, inlineContent } = parseFenceStart(line);
|
||||
index += 1;
|
||||
const codeLines: string[] = [];
|
||||
if (inlineContent) {
|
||||
codeLines.push(inlineContent);
|
||||
}
|
||||
|
||||
while (index < lines.length && !isCodeFence(lines[index])) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const text = headingMatch[2].trim();
|
||||
const id = slugifyHeading(text);
|
||||
if (headings.length === 0 && text) {
|
||||
title = text;
|
||||
}
|
||||
headings.push({ level, text, id });
|
||||
parts.push(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath, resolveDocsLink)}</h${level}>`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
||||
quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath, resolveDocsLink)}</p>`).join('')}</blockquote>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
parts.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}</li>`).join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
parts.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}</li>`).join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\|/.test(line)) {
|
||||
const tableLines: string[] = [];
|
||||
while (index < lines.length && /^\|/.test(lines[index])) {
|
||||
tableLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!isCodeFence(lines[index]) &&
|
||||
!/^\s*>\s?/.test(lines[index]) &&
|
||||
!/^\s*[-*]\s+/.test(lines[index]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[index]) &&
|
||||
!/^\|/.test(lines[index])
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (paragraphLines.length > 0) {
|
||||
pushParagraph(parts, paragraphLines, currentDocPath, resolveDocsLink);
|
||||
continue;
|
||||
}
|
||||
|
||||
pushParagraph(parts, [line.trim()], currentDocPath, resolveDocsLink);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push('<p>No rendered documentation content is available for this entry.</p>');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
headings,
|
||||
html: parts.join('\n'),
|
||||
};
|
||||
}
|
||||
@@ -11,177 +11,7 @@ import {
|
||||
parseDocsUrl,
|
||||
resolveDocsLink,
|
||||
} from '../../core/navigation/docs-route';
|
||||
|
||||
interface DocsHeading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RenderedDocument {
|
||||
title: string;
|
||||
headings: DocsHeading[];
|
||||
html: string;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function slugifyHeading(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(value: string, currentDocPath: string): string {
|
||||
let rendered = escapeHtml(value);
|
||||
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => {
|
||||
const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#';
|
||||
const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref);
|
||||
const attributes = isExternal ? ' target="_blank" rel="noopener"' : '';
|
||||
return `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
|
||||
});
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderMarkdownDocument(markdown: string, currentDocPath: string): RenderedDocument {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const parts: string[] = [];
|
||||
const headings: DocsHeading[] = [];
|
||||
let title = 'Documentation';
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codeFenceMatch = line.match(/^```(\w+)?\s*$/);
|
||||
if (codeFenceMatch) {
|
||||
const language = codeFenceMatch[1]?.trim() ?? '';
|
||||
index++;
|
||||
const codeLines: string[] = [];
|
||||
while (index < lines.length && !/^```/.test(lines[index])) {
|
||||
codeLines.push(lines[index]);
|
||||
index++;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const text = headingMatch[2].trim();
|
||||
const id = slugifyHeading(text);
|
||||
if (headings.length === 0 && text) {
|
||||
title = text;
|
||||
}
|
||||
headings.push({ level, text, id });
|
||||
parts.push(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath)}</h${level}>`);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
||||
quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath)}</p>`).join('')}</blockquote>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\|/.test(line)) {
|
||||
const tableLines: string[] = [];
|
||||
while (index < lines.length && /^\|/.test(lines[index])) {
|
||||
tableLines.push(lines[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!/^```/.test(lines[index]) &&
|
||||
!/^\s*>\s?/.test(lines[index]) &&
|
||||
!/^\s*[-*]\s+/.test(lines[index]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[index]) &&
|
||||
!/^\|/.test(lines[index])
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}</p>`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push('<p>No rendered documentation content is available for this entry.</p>');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
headings,
|
||||
html: parts.join('\n'),
|
||||
};
|
||||
}
|
||||
import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-markdown';
|
||||
|
||||
@Component({
|
||||
selector: 'app-docs-viewer',
|
||||
@@ -492,7 +322,7 @@ export class DocsViewerComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderMarkdownDocument(markdown, path);
|
||||
const rendered = renderMarkdownDocument(markdown, path, resolveDocsLink);
|
||||
this.title.set(rendered.title);
|
||||
this.headings.set(rendered.headings);
|
||||
this.resolvedAssetPath.set(candidate);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideRouter, Router, RouterLink } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { TRUST_API, type TrustApi } from '../../core/api/trust.client';
|
||||
@@ -35,9 +36,9 @@ describe('TrustAdminComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
trustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
trustApi = jasmine.createSpyObj('TrustApi', [
|
||||
'getAdministrationOverview',
|
||||
]);
|
||||
]) as unknown as jasmine.SpyObj<TrustApi>;
|
||||
trustApi.getAdministrationOverview.and.returnValue(of(overviewFixture));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -90,4 +91,15 @@ describe('TrustAdminComponent', () => {
|
||||
|
||||
expect(component.activeTab()).toBe('overview');
|
||||
});
|
||||
|
||||
it('preserves scope query params on every trust shell tab', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const tabLinks = fixture.debugElement
|
||||
.queryAll(By.directive(RouterLink))
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink));
|
||||
|
||||
expect(tabLinks.length).toBe(9);
|
||||
expect(tabLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
[class.trust-admin__tab--active]="activeTab() === 'overview'"
|
||||
routerLink="overview"
|
||||
[queryParams]="{}"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'overview'"
|
||||
>
|
||||
@@ -131,6 +132,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'keys'"
|
||||
routerLink="keys"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'keys'"
|
||||
>
|
||||
@@ -143,6 +145,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'issuers'"
|
||||
routerLink="issuers"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'issuers'"
|
||||
>
|
||||
@@ -152,6 +155,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'certificates'"
|
||||
routerLink="certificates"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'certificates'"
|
||||
>
|
||||
@@ -164,6 +168,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'watchlist'"
|
||||
routerLink="watchlist"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'watchlist'"
|
||||
>
|
||||
@@ -173,6 +178,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'audit'"
|
||||
routerLink="audit"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'audit'"
|
||||
>
|
||||
@@ -182,6 +188,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'airgap'"
|
||||
routerLink="airgap"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'airgap'"
|
||||
>
|
||||
@@ -191,6 +198,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'incidents'"
|
||||
routerLink="incidents"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'incidents'"
|
||||
>
|
||||
@@ -200,6 +208,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
class="trust-admin__tab"
|
||||
[class.trust-admin__tab--active]="activeTab() === 'analytics'"
|
||||
routerLink="analytics"
|
||||
queryParamsHandling="merge"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'analytics'"
|
||||
>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
"src/app/core/console/console-status.service.spec.ts",
|
||||
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
|
||||
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
|
||||
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
|
||||
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
|
||||
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
|
||||
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
|
||||
"src/app/features/docs/docs-markdown.spec.ts",
|
||||
"src/app/features/evidence-export/evidence-bundles.component.spec.ts",
|
||||
"src/app/features/evidence-export/export-center.component.spec.ts",
|
||||
"src/app/features/evidence-export/provenance-visualization.component.spec.ts",
|
||||
@@ -28,6 +30,7 @@
|
||||
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
|
||||
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-admin.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.spec.ts",
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
|
||||
Reference in New Issue
Block a user