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({

View File

@@ -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();

View File

@@ -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>
}

View File

@@ -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');
});
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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'),
};
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);

View File

@@ -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();
});
});

View File

@@ -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'"
>

View File

@@ -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",