Align route ownership and sidebar surface exposure
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Sprint 20260310_028 - FE Route Surface Ownership Alignment
|
||||
|
||||
## Topic & Scope
|
||||
- Align the live web shell so notifications, environment inventory, release health, and audit/security navigation point to the canonical owning surfaces.
|
||||
- Repair the route-level and return-navigation regressions left behind by the in-flight cleanup, especially around Mission Control watchlist handoffs.
|
||||
- Working directory: `src/Web/StellaOps.Web/src/app/routes`.
|
||||
- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/layout/app-sidebar`, `src/Web/StellaOps.Web/src/app/features/platform/ops`, `src/Web/StellaOps.Web/src/app/features/watchlist`, `src/Web/StellaOps.Web/src/app/core/testing`, `docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md`.
|
||||
- Expected evidence: focused Angular route/sidebar/watchlist specs, rebuilt web bundle, live Playwright route/action checks on the changed surfaces.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the live compose stack from the scratch-setup iteration.
|
||||
- Safe parallelism: do not mix unrelated page-revival edits into this slice; keep it bounded to route ownership, sidebar exposure, and watchlist handoff semantics.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/features/checked/web/left-rail-navigation-shell.md`
|
||||
- `docs/features/checked/web/identity-watchlist-management-ui.md`
|
||||
- `docs/features/checked/web/platform-setup-canonical-route-preservation-ui.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-ROUTE-OWNERSHIP-001 - Align canonical route ownership and sidebar exposure
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA, Developer
|
||||
Task description:
|
||||
- The dirty web slice is consolidating notifications and environment inventory under Operations, adding Release Health under Releases, and re-grouping sidebar ownership between Release Control, Security & Audit, and Platform & Setup.
|
||||
- Finish the cleanup by validating the route contracts in code, restoring any dropped scope-preservation coverage that is still required, and correcting watchlist return semantics so dedicated Mission Control leaves remain truthful.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Route specs prove the canonical owners for `/ops/operations/notifications`, `/ops/operations/environments`, `/releases/health`, and the legacy environment redirects.
|
||||
- [x] Sidebar spec proves the new exposure model without reintroducing removed Mission Control child leaves.
|
||||
- [x] Watchlist return labels distinguish `Mission Alerts`, `Dashboard`, and `Notifications`.
|
||||
- [x] Rebuilt live web passes the affected Playwright route/action checks with zero failures.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-10 | Sprint created while auditing the remaining dirty route/sidebar slice after the Mission Control iteration. Confirmed the change set is a route-surface ownership cleanup, not the unrelated OpenAPI/header draft sprint. | Developer |
|
||||
| 2026-03-10 | Added `route-surface-ownership.spec.ts`, restored the dropped Mission Control scope assertions, and added the missing watchlist return-label coverage. `npx ng test --watch=false --include=src/app/routes/route-surface-ownership.spec.ts --include=src/app/routes/releases.routes.spec.ts --include=src/app/layout/app-sidebar/app-sidebar.component.spec.ts --include=src/app/core/testing/mission-scope-links.component.spec.ts` passed `27/27`; `npx ng test --watch=false --ts-config tsconfig.spec.features.json --include=src/app/features/watchlist/watchlist-page.component.spec.ts` passed `9/9`. | Developer |
|
||||
| 2026-03-10 | Rebuilt the web bundle, resynced `dist/stellaops-web/browser` into `compose_console-dist`, and verified the live route/sidebar ownership slice with `node ./scripts/live-route-surface-ownership-check.mjs` (`failedActionCount=0`, `runtimeIssueCount=0`). | QA |
|
||||
| 2026-03-10 | Adjacent live check `node ./scripts/live-notifications-watchlist-recheck.mjs` still fails on the Notifications surface (`Notification Administration`) because the watchlist tuning and alert drilldown links are missing and the page raised a visible `!t.items is not iterable` banner. Kept that defect out of this scoped commit as the next iteration. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: keep environment inventory under Operations and treat Releases-owned environment routes as compatibility redirects only.
|
||||
- Decision: preserve dedicated Mission Control alert semantics end to end; restoring the alerts leaf also requires preserving `Mission Alerts` return labels in watchlist drilldowns.
|
||||
- Decision: keep a dedicated Playwright harness (`live-route-surface-ownership-check.mjs`) for this cleanup so future route/shell ownership changes can be reverified without rerunning the full canonical sweep.
|
||||
- Risk: unrelated page-level UI edits are still present in the dirty tree. They must stay out of this commit unless they are independently verified.
|
||||
- Risk: Notifications still has a separate live defect (`!t.items is not iterable`, missing watchlist links). That surface needs its own follow-up iteration before the broader product can be considered clean.
|
||||
|
||||
## Next Checkpoints
|
||||
- Land focused route/watchlist/spec coverage.
|
||||
- Rebuild and sync the web bundle into `compose_console-dist`.
|
||||
- Re-run live Playwright on the changed route/action surfaces and commit the verified slice.
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-route-surface-ownership-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-route-surface-ownership-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-route-surface-ownership-check.auth.json');
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function attachRuntimeObservers(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.pageErrors.push({
|
||||
page: page.url(),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (isStaticAsset(request.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||
if (errorText === 'net::ERR_ABORTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (isStaticAsset(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
|
||||
const count = await headings.count();
|
||||
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
||||
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
heading: await headingText(page),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistSummary(summary) {
|
||||
summary.lastUpdatedAtUtc = new Date().toISOString();
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function runRouteCheck(page, routeCheck) {
|
||||
const evaluate = async () => {
|
||||
const currentUrl = new URL(page.url());
|
||||
const title = await page.title();
|
||||
const heading = await headingText(page);
|
||||
let ok = currentUrl.pathname === routeCheck.expectedPath;
|
||||
|
||||
if (ok && routeCheck.expectedTitle && !routeCheck.expectedTitle.test(title)) {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok && routeCheck.expectedHeading && !routeCheck.expectedHeading.test(heading)) {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
return {
|
||||
ok,
|
||||
finalUrl: page.url(),
|
||||
snapshot: await captureSnapshot(page, `route:${routeCheck.path}`),
|
||||
};
|
||||
};
|
||||
|
||||
await page.goto(`https://stella-ops.local${routeCheck.path}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
let result = await evaluate();
|
||||
if (!result.ok && new URL(result.finalUrl).pathname === new URL(`https://stella-ops.local${routeCheck.path}`).pathname) {
|
||||
await page.waitForTimeout(2_000);
|
||||
result = await evaluate();
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'route',
|
||||
route: routeCheck.path,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSidebarCheck(page) {
|
||||
await page.goto(`https://stella-ops.local/mission-control/board?${scopeQuery}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
const hrefs = await page.locator('aside.sidebar a').evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getAttribute('href'))
|
||||
.filter((value) => typeof value === 'string'),
|
||||
);
|
||||
|
||||
const required = [
|
||||
'/releases/health',
|
||||
'/ops/operations/environments',
|
||||
'/ops/operations/notifications',
|
||||
];
|
||||
const forbidden = [
|
||||
'/setup/notifications',
|
||||
'/mission-control/alerts',
|
||||
'/mission-control/activity',
|
||||
];
|
||||
|
||||
const ok = required.every((href) => hrefs.includes(href))
|
||||
&& forbidden.every((href) => !hrefs.includes(href));
|
||||
|
||||
return {
|
||||
kind: 'sidebar',
|
||||
route: `/mission-control/board?${scopeQuery}`,
|
||||
ok,
|
||||
hrefs,
|
||||
snapshot: await captureSnapshot(page, 'sidebar:mission-control-board'),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWatchlistLabelCheck(page, returnTo, expectedLabel) {
|
||||
await page.goto(
|
||||
`https://stella-ops.local/setup/trust-signing/watchlist/alerts?${scopeQuery}&alertId=alert-001&scope=tenant&tab=alerts&returnTo=${encodeURIComponent(returnTo)}`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await settle(page);
|
||||
|
||||
const labelVisible = await page.getByText(`Return to ${expectedLabel}`, { exact: false }).first().isVisible().catch(() => false);
|
||||
|
||||
return {
|
||||
kind: 'watchlist-return',
|
||||
route: page.url(),
|
||||
ok: labelVisible,
|
||||
expectedLabel,
|
||||
snapshot: await captureSnapshot(page, `watchlist-return:${expectedLabel}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, {
|
||||
statePath: authStatePath,
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
context.on('page', (page) => attachRuntimeObservers(page, runtime));
|
||||
|
||||
const page = await context.newPage();
|
||||
attachRuntimeObservers(page, runtime);
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
results: [],
|
||||
runtime,
|
||||
};
|
||||
|
||||
const routeChecks = [
|
||||
{
|
||||
path: `/releases/health?${scopeQuery}`,
|
||||
expectedPath: '/releases/health',
|
||||
expectedTitle: /release health/i,
|
||||
},
|
||||
{
|
||||
path: `/releases/environments?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/environments',
|
||||
expectedTitle: /environments/i,
|
||||
},
|
||||
{
|
||||
path: `/release-control/environments?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/environments',
|
||||
expectedTitle: /environments/i,
|
||||
},
|
||||
{
|
||||
path: `/setup/notifications?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/notifications',
|
||||
expectedTitle: /notifications/i,
|
||||
},
|
||||
{
|
||||
path: `/ops/operations/notifications?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/notifications',
|
||||
expectedTitle: /notifications/i,
|
||||
},
|
||||
];
|
||||
|
||||
for (const routeCheck of routeChecks) {
|
||||
summary.results.push(await runRouteCheck(page, routeCheck));
|
||||
await persistSummary(summary);
|
||||
}
|
||||
|
||||
summary.results.push(await runSidebarCheck(page));
|
||||
await persistSummary(summary);
|
||||
|
||||
summary.results.push(await runWatchlistLabelCheck(page, '/mission-control/alerts', 'Mission Alerts'));
|
||||
await persistSummary(summary);
|
||||
|
||||
summary.results.push(await runWatchlistLabelCheck(page, '/mission-control/board', 'Dashboard'));
|
||||
await persistSummary(summary);
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
const failedActionCount = summary.results.filter((entry) => !entry.ok).length;
|
||||
const runtimeIssueCount = runtime.consoleErrors.length
|
||||
+ runtime.pageErrors.length
|
||||
+ runtime.requestFailures.length
|
||||
+ runtime.responseErrors.length;
|
||||
|
||||
summary.failedActionCount = failedActionCount;
|
||||
summary.runtimeIssueCount = runtimeIssueCount;
|
||||
await persistSummary(summary);
|
||||
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
|
||||
if (failedActionCount > 0 || runtimeIssueCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[live-route-surface-ownership-check] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -323,8 +323,8 @@ export const routes: Routes = [
|
||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' },
|
||||
{ path: 'environments', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
|
||||
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
|
||||
|
||||
@@ -588,7 +588,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'admin-notifications',
|
||||
label: 'Notification Admin',
|
||||
route: '/setup/notifications',
|
||||
route: '/ops/operations/notifications',
|
||||
icon: 'bell-config',
|
||||
tooltip: 'Configure notification rules, channels, and templates',
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ import { By } from '@angular/platform-browser';
|
||||
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
import { DashboardV3Component } from '../../features/dashboard-v3/dashboard-v3.component';
|
||||
import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component';
|
||||
import { MissionActivityPageComponent } from '../../features/mission-control/mission-activity-page.component';
|
||||
import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component';
|
||||
|
||||
function routerLinksFor<T>(component: T): RouterLink[] {
|
||||
const fixture = TestBed.createComponent(component as never);
|
||||
|
||||
@@ -113,15 +113,6 @@ export class PlatformOpsOverviewPageComponent {
|
||||
route: OPERATIONS_PATHS.aoc,
|
||||
owner: 'Ops',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
detail: 'Critical operator alerts, escalation channels, and delivery status.',
|
||||
metric: '2 paging alerts',
|
||||
impact: 'degraded',
|
||||
route: OPERATIONS_PATHS.notifications,
|
||||
owner: 'Ops',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -137,4 +137,15 @@ describe('WatchlistPageComponent', () => {
|
||||
expect(component.testResult()).not.toBeNull();
|
||||
expect(component.testResult()?.matches).toBeTrue();
|
||||
});
|
||||
|
||||
it('labels mission-control return paths precisely', () => {
|
||||
component.returnTo.set('/mission-control/alerts');
|
||||
expect(component.returnToLabel()).toBe('Mission Alerts');
|
||||
|
||||
component.returnTo.set('/mission-control/board');
|
||||
expect(component.returnToLabel()).toBe('Dashboard');
|
||||
|
||||
component.returnTo.set('/ops/operations/notifications');
|
||||
expect(component.returnToLabel()).toBe('Notifications');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,6 +838,10 @@ export class WatchlistPageComponent implements OnInit {
|
||||
return 'Mission Alerts';
|
||||
}
|
||||
|
||||
if (returnTo.includes('/mission-control/board')) {
|
||||
return 'Dashboard';
|
||||
}
|
||||
|
||||
if (returnTo.includes('/notifications')) {
|
||||
return 'Notifications';
|
||||
}
|
||||
|
||||
@@ -35,11 +35,21 @@ describe('AppSidebarComponent', () => {
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Dashboard');
|
||||
expect(text).toContain('Alerts');
|
||||
expect(text).toContain('Activity');
|
||||
expect(text).not.toContain('Analytics');
|
||||
});
|
||||
|
||||
it('renders Dashboard as childless root item', () => {
|
||||
setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ, StellaOpsScopes.SCANNER_READ]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
// Dashboard link exists but no alerts/activity children
|
||||
expect(hrefs).toContain('/mission-control/board');
|
||||
expect(hrefs).not.toContain('/mission-control/alerts');
|
||||
expect(hrefs).not.toContain('/mission-control/activity');
|
||||
});
|
||||
|
||||
it('starts edge auto-scroll animation only when pointer enters edge zone', () => {
|
||||
setScopes([StellaOpsScopes.UI_READ]);
|
||||
const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(1);
|
||||
@@ -70,7 +80,7 @@ describe('AppSidebarComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Trust & Signing');
|
||||
});
|
||||
|
||||
it('surfaces mission, unknowns, and notifications leaves in the live sidebar shells', () => {
|
||||
it('surfaces unknowns and notifications leaves in the live sidebar shells', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_READ,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
@@ -83,12 +93,81 @@ describe('AppSidebarComponent', () => {
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/mission-control/alerts');
|
||||
expect(hrefs).toContain('/mission-control/activity');
|
||||
expect(hrefs).toContain('/security/unknowns');
|
||||
expect(hrefs).toContain('/ops/operations/notifications');
|
||||
});
|
||||
|
||||
it('shows Health under Releases section', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_READ,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/releases/health');
|
||||
});
|
||||
|
||||
it('shows Security Posture under Vulnerabilities section', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/security/posture');
|
||||
});
|
||||
|
||||
it('shows Audit section with Logs and Bundles', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_READ,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Audit');
|
||||
expect(text).toContain('Logs');
|
||||
expect(text).toContain('Bundles');
|
||||
});
|
||||
|
||||
it('shows Operations children: Scheduled Jobs, Diagnostics, Signals, Offline Kit, Environments', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.HEALTH_READ,
|
||||
StellaOpsScopes.NOTIFY_VIEWER,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/ops/operations/jobengine');
|
||||
expect(hrefs).toContain('/ops/operations/doctor');
|
||||
expect(hrefs).toContain('/ops/operations/signals');
|
||||
expect(hrefs).toContain('/ops/operations/offline-kit');
|
||||
expect(hrefs).toContain('/ops/operations/environments');
|
||||
});
|
||||
|
||||
it('does not show Notifications under Setup', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).not.toContain('/setup/notifications');
|
||||
});
|
||||
|
||||
function setScopes(scopes: readonly StellaOpsScope[]): void {
|
||||
const baseUser = authService.user();
|
||||
if (!baseUser) {
|
||||
|
||||
@@ -624,6 +624,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
* Root modules: Mission Control, Releases, Security, Evidence, Ops, Setup.
|
||||
*/
|
||||
readonly navSections: NavSection[] = [
|
||||
// ── Release Control ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
@@ -636,12 +637,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
],
|
||||
children: [
|
||||
{ id: 'mc-alerts', label: 'Alerts', route: '/mission-control/alerts', icon: 'bell' },
|
||||
{ id: 'mc-activity', label: 'Activity', route: '/mission-control/activity', icon: 'clock' },
|
||||
{ id: 'mc-release-health', label: 'Release Health', route: '/mission-control/release-health', icon: 'activity' },
|
||||
{ id: 'mc-security-posture', label: 'Security Posture', route: '/mission-control/security-posture', icon: 'shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'releases',
|
||||
@@ -658,17 +653,16 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
children: [
|
||||
{
|
||||
id: 'rel-versions',
|
||||
label: 'Release Versions',
|
||||
label: 'Versions',
|
||||
route: '/releases/versions',
|
||||
icon: 'package',
|
||||
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
|
||||
},
|
||||
{
|
||||
id: 'rel-runs',
|
||||
label: 'Release Runs',
|
||||
route: '/releases/runs',
|
||||
icon: 'clock',
|
||||
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
|
||||
id: 'rel-health',
|
||||
label: 'Health',
|
||||
route: '/releases/health',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
id: 'rel-approvals',
|
||||
@@ -695,23 +689,43 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
},
|
||||
{ id: 'rel-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' },
|
||||
{ id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' },
|
||||
{
|
||||
id: 'rel-create',
|
||||
label: 'Create Version',
|
||||
route: '/releases/versions/new',
|
||||
icon: 'settings',
|
||||
requireAnyScope: [StellaOpsScopes.RELEASE_WRITE, StellaOpsScopes.RELEASE_PUBLISH],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
label: 'Operations',
|
||||
icon: 'settings',
|
||||
route: '/ops/operations',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
sparklineData$: () => this.doctorTrendService.platformTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.HEALTH_READ,
|
||||
StellaOpsScopes.NOTIFY_VIEWER,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
],
|
||||
children: [
|
||||
{ id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' },
|
||||
{ id: 'ops-diagnostics', label: 'Diagnostics', route: '/ops/operations/doctor', icon: 'stethoscope' },
|
||||
{ id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' },
|
||||
{ id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' },
|
||||
{ id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' },
|
||||
{ id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' },
|
||||
{ id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' },
|
||||
{ id: 'ops-notifications', label: 'Notifications', route: '/ops/operations/notifications', icon: 'bell' },
|
||||
],
|
||||
},
|
||||
// ── Security & Audit ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'vulnerabilities',
|
||||
label: 'Vulnerabilities',
|
||||
icon: 'shield',
|
||||
route: '/security',
|
||||
menuGroupId: 'security-evidence',
|
||||
menuGroupLabel: 'Security & Evidence',
|
||||
menuGroupId: 'security-audit',
|
||||
menuGroupLabel: 'Security & Audit',
|
||||
sparklineData$: () => this.doctorTrendService.securityTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
@@ -724,20 +738,20 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
children: [
|
||||
{ id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' },
|
||||
{ id: 'sec-audit-bundles', label: 'Audit Bundles', route: '/triage/audit-bundles', icon: 'archive' },
|
||||
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
|
||||
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
|
||||
{ id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' },
|
||||
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
|
||||
{ id: 'sec-posture', label: 'Security Posture', route: '/security/posture', icon: 'shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence',
|
||||
label: 'Evidence',
|
||||
id: 'audit',
|
||||
label: 'Audit',
|
||||
icon: 'file-text',
|
||||
route: '/evidence/overview',
|
||||
menuGroupId: 'security-evidence',
|
||||
menuGroupLabel: 'Security & Evidence',
|
||||
menuGroupId: 'security-audit',
|
||||
menuGroupLabel: 'Security & Audit',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
@@ -750,31 +764,11 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
{ id: 'ev-capsules', label: 'Decision Capsules', route: '/evidence/capsules', icon: 'archive' },
|
||||
{ id: 'ev-verify', label: 'Replay & Verify', route: '/evidence/verify-replay', icon: 'refresh' },
|
||||
{ id: 'ev-exports', label: 'Export Center', route: '/evidence/exports', icon: 'download' },
|
||||
{ id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
label: 'Operations',
|
||||
icon: 'settings',
|
||||
route: '/ops/operations',
|
||||
menuGroupId: 'platform-setup',
|
||||
menuGroupLabel: 'Platform & Setup',
|
||||
sparklineData$: () => this.doctorTrendService.platformTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.HEALTH_READ,
|
||||
StellaOpsScopes.NOTIFY_VIEWER,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
],
|
||||
children: [
|
||||
{ id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' },
|
||||
{ id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' },
|
||||
{ id: 'ops-notifications', label: 'Notifications', route: '/ops/operations/notifications', icon: 'bell' },
|
||||
{ id: 'ev-audit', label: 'Logs', route: '/evidence/audit-log', icon: 'book-open' },
|
||||
{ id: 'ev-bundles', label: 'Bundles', route: '/triage/audit-bundles', icon: 'archive' },
|
||||
],
|
||||
},
|
||||
// ── Platform & Setup ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'setup',
|
||||
label: 'Setup',
|
||||
@@ -794,7 +788,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
{ id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' },
|
||||
{ id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' },
|
||||
{ id: 'setup-branding', label: 'Tenant & Branding', route: '/setup/tenant-branding', icon: 'paintbrush' },
|
||||
{ id: 'setup-notifications', label: 'Notifications', route: '/setup/notifications', icon: 'bell' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -818,7 +811,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
/** Menu groups rendered in deterministic order for scanability */
|
||||
readonly displaySectionGroups = computed<NavSectionGroup[]>(() => {
|
||||
const orderedGroups = new Map<string, NavSectionGroup>();
|
||||
const groupOrder = ['release-control', 'security-evidence', 'platform-setup', 'misc'];
|
||||
const groupOrder = ['release-control', 'security-audit', 'platform-setup', 'misc'];
|
||||
|
||||
for (const groupId of groupOrder) {
|
||||
orderedGroups.set(groupId, {
|
||||
@@ -890,8 +883,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
switch (groupId) {
|
||||
case 'release-control':
|
||||
return 'Release Control';
|
||||
case 'security-evidence':
|
||||
return 'Security & Evidence';
|
||||
case 'security-audit':
|
||||
return 'Security & Audit';
|
||||
case 'platform-setup':
|
||||
return 'Platform & Setup';
|
||||
default:
|
||||
|
||||
@@ -225,7 +225,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
||||
},
|
||||
{
|
||||
path: 'release-orchestrator/environments',
|
||||
redirectTo: '/topology/regions',
|
||||
redirectTo: '/ops/operations/environments',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'release-control/environments',
|
||||
redirectTo: '/ops/operations/environments',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -191,8 +191,26 @@ export const OPERATIONS_ROUTES: Routes = [
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
loadChildren: () =>
|
||||
import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
title: 'Environments Inventory',
|
||||
data: { breadcrumb: 'Environments' },
|
||||
loadComponent: () =>
|
||||
import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent),
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'environments/:environmentId',
|
||||
title: 'Environment Detail',
|
||||
data: { breadcrumb: 'Environment Detail' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-environment-detail-page.component').then(
|
||||
(m) => m.TopologyEnvironmentDetailPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'status',
|
||||
|
||||
@@ -181,22 +181,24 @@ export const RELEASES_ROUTES: Routes = [
|
||||
import('../features/releases/hotfix-detail-page.component').then((m) => m.HotfixDetailPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
title: 'Environments Inventory',
|
||||
data: { breadcrumb: 'Environments' },
|
||||
path: 'health',
|
||||
title: 'Release Health',
|
||||
data: { breadcrumb: 'Health' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
import('../features/topology/environment-posture-page.component').then(
|
||||
(m) => m.EnvironmentPosturePageComponent,
|
||||
),
|
||||
},
|
||||
// Redirect environments to Operations (moved from Releases)
|
||||
{
|
||||
path: 'environments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments'),
|
||||
},
|
||||
{
|
||||
path: 'environments/:environmentId',
|
||||
title: 'Environment Detail',
|
||||
data: { breadcrumb: 'Environment Detail' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-environment-detail-page.component').then(
|
||||
(m) => m.TopologyEnvironmentDetailPageComponent,
|
||||
),
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments/:environmentId'),
|
||||
},
|
||||
{
|
||||
path: 'deployments',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, type Route } from '@angular/router';
|
||||
|
||||
import { routes } from '../app.routes';
|
||||
import { OPERATIONS_ROUTES } from './operations.routes';
|
||||
import { RELEASES_ROUTES } from './releases.routes';
|
||||
import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from './legacy-redirects.routes';
|
||||
import { SETUP_ROUTES } from './setup.routes';
|
||||
|
||||
type RedirectFn = Exclude<NonNullable<Route['redirectTo']>, string>;
|
||||
|
||||
function invokeRedirect(redirectTo: RedirectFn, snapshot: {
|
||||
params?: Record<string, string>;
|
||||
queryParams?: Record<string, string>;
|
||||
fragment?: string | null;
|
||||
}): string {
|
||||
return TestBed.runInInjectionContext(() => redirectTo(snapshot as never)).toString();
|
||||
}
|
||||
|
||||
function findRouteByPath(routeList: readonly Route[], path: string): Route | undefined {
|
||||
for (const route of routeList) {
|
||||
if (route.path === path) {
|
||||
return route;
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
const nested = findRouteByPath(route.children, path);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('Route surface ownership', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects root environment shortcuts to Operations', () => {
|
||||
const environmentsRoute = findRouteByPath(routes, 'environments');
|
||||
const regionsRoute = findRouteByPath(routes, 'regions');
|
||||
|
||||
expect(environmentsRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
});
|
||||
|
||||
it('preserves setup notifications redirects into Operations notifications', () => {
|
||||
const notificationsRoute = SETUP_ROUTES.find((route) => route.path === 'notifications');
|
||||
|
||||
expect(notificationsRoute?.pathMatch).toBe('prefix');
|
||||
expect(typeof notificationsRoute?.redirectTo).toBe('function');
|
||||
|
||||
const notificationsRedirect = notificationsRoute?.redirectTo;
|
||||
if (typeof notificationsRedirect !== 'function') {
|
||||
throw new Error('Setup notifications route must expose a redirect function.');
|
||||
}
|
||||
|
||||
const target = invokeRedirect(notificationsRedirect, {
|
||||
params: {},
|
||||
queryParams: { tenant: 'demo-prod', regions: 'us-east' },
|
||||
fragment: 'channels',
|
||||
});
|
||||
|
||||
expect(target).toBe('/ops/operations/notifications?tenant=demo-prod®ions=us-east#channels');
|
||||
});
|
||||
|
||||
it('mounts Operations ownership for notifications and environments', () => {
|
||||
const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications');
|
||||
const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments');
|
||||
const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
||||
|
||||
expect(typeof notificationsRoute?.loadChildren).toBe('function');
|
||||
expect(typeof environmentsRoute?.loadComponent).toBe('function');
|
||||
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('keeps release health under Releases while redirecting release environments to Operations', () => {
|
||||
const healthRoute = RELEASES_ROUTES.find((route) => route.path === 'health');
|
||||
const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments');
|
||||
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
||||
|
||||
expect(healthRoute?.title).toBe('Release Health');
|
||||
expect(typeof healthRoute?.loadComponent).toBe('function');
|
||||
|
||||
const environmentsRedirect = environmentsRoute?.redirectTo;
|
||||
if (typeof environmentsRedirect !== 'function') {
|
||||
throw new Error('Releases environments route must expose a redirect function.');
|
||||
}
|
||||
const environmentDetailRedirect = environmentDetailRoute?.redirectTo;
|
||||
if (typeof environmentDetailRedirect !== 'function') {
|
||||
throw new Error('Releases environment detail route must expose a redirect function.');
|
||||
}
|
||||
|
||||
const environmentsTarget = invokeRedirect(environmentsRedirect, {
|
||||
params: {},
|
||||
queryParams: { tenant: 'demo-prod' },
|
||||
});
|
||||
const environmentDetailTarget = invokeRedirect(environmentDetailRedirect, {
|
||||
params: { environmentId: 'stage' },
|
||||
queryParams: { tenant: 'demo-prod' },
|
||||
});
|
||||
|
||||
expect(environmentsTarget).toBe('/ops/operations/environments?tenant=demo-prod');
|
||||
expect(environmentDetailTarget).toBe('/ops/operations/environments/stage?tenant=demo-prod');
|
||||
});
|
||||
|
||||
it('maps legacy release environment shortcuts to Operations inventory', () => {
|
||||
const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find(
|
||||
(route) => route.path === 'release-orchestrator/environments',
|
||||
);
|
||||
const releaseControlRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find(
|
||||
(route) => route.path === 'release-control/environments',
|
||||
);
|
||||
|
||||
expect(releaseOrchestratorRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
expect(releaseControlRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
|
||||
export const SETUP_ROUTES: Routes = [
|
||||
{
|
||||
@@ -26,12 +27,17 @@ export const SETUP_ROUTES: Routes = [
|
||||
(m) => m.BrandingSettingsPageComponent,
|
||||
),
|
||||
},
|
||||
// Redirect to consolidated notifications under Operations
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
loadChildren: () =>
|
||||
import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
|
||||
pathMatch: 'prefix',
|
||||
redirectTo: ({ queryParams, fragment }) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl('/ops/operations/notifications');
|
||||
target.queryParams = { ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
|
||||
Reference in New Issue
Block a user