Merge origin/main into claude/patrol-telemetry-provider-cost-v4

Keeps this change's browser verification receipt; main's receipt belonged to the
navigation change that landed in #1863.
This commit is contained in:
rcourtman
2026-09-02 11:52:20 +01:00
26 changed files with 245 additions and 130 deletions
+5
View File
@@ -7,6 +7,7 @@ import { SecurityWarning } from './components/SecurityWarning';
import { Login } from './components/Login';
import { logger } from './utils/logger';
import { UpdateBanner } from './components/UpdateBanner';
import { SkipToContentLink } from './components/shared/SkipToContentLink';
import { WhatsNewCard } from './components/WhatsNewCard';
import { DemoBanner } from './components/DemoBanner';
import { CommercialMigrationBanner } from './components/CommercialMigrationBanner';
@@ -532,6 +533,10 @@ function App() {
>
<WebSocketContext.Provider value={runtime.enhancedStore()!}>
<DarkModeContext.Provider value={runtime.darkMode}>
{/* First focusable element in the document: the skip link
has to precede the banners below, or Tab from the page
start lands on a banner control instead. */}
<SkipToContentLink />
{/* Global banners deep-link into settings (security
hardening, telemetry preferences, license management),
so they are for sessions that can actually reach those
+4 -21
View File
@@ -255,10 +255,8 @@ export function AppLayout(props: AppLayoutProps) {
const browserBrandName = createMemo(() => customBrandName() || 'Pulse');
const [headerVisible, setHeaderVisible] = createSignal(true);
const [skipLinkFocused, setSkipLinkFocused] = createSignal(false);
const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0);
let headerEl: HTMLDivElement | undefined;
let mainContentEl: HTMLElement | undefined;
let assistantLauncherEl: HTMLButtonElement | undefined;
let restoreAssistantLauncherFocus = false;
let headerHideTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -726,22 +724,8 @@ export function AppLayout(props: AppLayoutProps) {
<div
class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''} ${!kioskMode() ? 'pb-safe-or-14 xl:pb-0' : ''}`}
>
{/* Skip-to-content link: visually hidden until focused, then
appears as a button at the top-left. Lets keyboard users
jump past the chrome straight into the page content. */}
<a
href="#main"
onClick={() => mainContentEl?.focus()}
onFocus={() => setSkipLinkFocused(true)}
onBlur={() => setSkipLinkFocused(false)}
class={
skipLinkFocused()
? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white'
: 'sr-only'
}
>
Skip to main content
</a>
{/* The skip-to-content link renders in App ahead of the global
banners; #main below is its target. */}
<Show when={kioskMode()}>
<div
class="fixed top-0 left-0 right-0 z-40 h-4 bg-transparent"
@@ -944,7 +928,7 @@ export function AppLayout(props: AppLayoutProps) {
</span>
)}
{tab.breakdown && tab.breakdown.warning > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-800 dark:text-slate-900 bg-amber-200 dark:bg-amber-400 rounded-full">
{tab.breakdown.warning}
</span>
)}
@@ -952,7 +936,7 @@ export function AppLayout(props: AppLayoutProps) {
);
}
return (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-800 dark:text-slate-900 bg-amber-200 dark:bg-amber-400 rounded-full">
{total}
</span>
);
@@ -982,7 +966,6 @@ export function AppLayout(props: AppLayoutProps) {
</Show>
<main
ref={mainContentEl}
id="main"
tabindex="-1"
class="tab-content mb-1 block rounded-b rounded-tl rounded-tr bg-surface shadow sm:mb-2"
@@ -178,10 +178,6 @@ describe('AppLayout navigation icons', () => {
expect(main).toHaveClass('mb-1', 'sm:mb-2');
expect(main).toHaveAttribute('id', 'main');
expect(main).toHaveAttribute('tabindex', '-1');
const skipLink = screen.getByRole('link', { name: 'Skip to main content' });
expect(skipLink).toHaveAttribute('href', '#main');
fireEvent.click(skipLink);
expect(main).toHaveFocus();
expect(screen.getByText('Preview')).toHaveClass('bg-orange-700', 'text-white');
expect(screen.getByText('Preview')).not.toHaveClass('bg-orange-500');
expect(container.querySelector('footer')).toHaveClass('pulse-footer', 'px-2', 'sm:px-4');
@@ -170,7 +170,7 @@ export function UpdateBanner() {
{/* Pre-release badge */}
<Show when={updateStore.updateInfo()?.isPrerelease && !isExpanded()}>
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200 rounded">
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded">
Pre-release
</span>
</Show>
@@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: {
</span>
</Show>
<Show when={badges().warning > 0}>
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-800">
{badges().warning}
</span>
</Show>
@@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: {
{(count) => (
<span
aria-hidden="true"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-800"
>
{count()}
</span>
@@ -0,0 +1,33 @@
import { Component, createSignal } from 'solid-js';
/**
* Skip-to-content link for keyboard and screen-reader users.
*
* It has to be the first focusable element in the document, ahead of every
* global banner (update, security, demo), so a single Tab from the page start
* reaches it. The shell renders it before those banners; AppLayout owns the
* `#main` target it jumps to. Visually hidden until focused, then shown as a
* button at the top-left.
*/
export const SkipToContentLink: Component<{ targetId?: string }> = (props) => {
const targetId = () => props.targetId ?? 'main';
const [focused, setFocused] = createSignal(false);
return (
<a
href={`#${targetId()}`}
onClick={() => document.getElementById(targetId())?.focus()}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
class={
focused()
? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white'
: 'sr-only'
}
>
Skip to main content
</a>
);
};
export default SkipToContentLink;
@@ -0,0 +1,41 @@
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
import { SkipToContentLink } from '@/components/shared/SkipToContentLink';
describe('SkipToContentLink', () => {
it('moves focus to the main landmark and reveals itself only while focused', () => {
render(() => (
<>
<SkipToContentLink />
<main id="main" tabindex="-1">
Content
</main>
</>
));
const skipLink = screen.getByRole('link', { name: 'Skip to main content' });
expect(skipLink).toHaveAttribute('href', '#main');
expect(skipLink).toHaveClass('sr-only');
fireEvent.focus(skipLink);
expect(skipLink).not.toHaveClass('sr-only');
fireEvent.blur(skipLink);
expect(skipLink).toHaveClass('sr-only');
fireEvent.click(skipLink);
expect(screen.getByRole('main')).toHaveFocus();
});
it('is rendered by the app shell ahead of the global banners', () => {
// Tab order follows DOM order. The update, security and demo banners
// render before AppLayout, so the link has to sit above that banner block
// or the first Tab lands on a banner control instead.
const skipLinkIndex = appSource.indexOf('<SkipToContentLink />');
const bannerBlockIndex = appSource.indexOf('<SecurityWarning />');
const layoutIndex = appSource.indexOf('<AppLayout');
expect(skipLinkIndex).toBeGreaterThan(-1);
expect(bannerBlockIndex).toBeGreaterThan(skipLinkIndex);
expect(layoutIndex).toBeGreaterThan(bannerBlockIndex);
});
});
+21 -2
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
@@ -20,6 +21,17 @@ sys.modules[SPEC.name] = docs_mirror
SPEC.loader.exec_module(docs_mirror)
# Background git processes (auto-gc, fsmonitor, maintenance) can still be
# writing under .git when TemporaryDirectory cleanup runs, which makes rmtree
# fail with "Directory not empty". Disable them for the throwaway repos and
# ignore host config so it cannot re-enable them.
GIT_ENV = {
**os.environ,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_SYSTEM": os.devnull,
}
def run_git(root: Path, *args: str) -> None:
subprocess.run(
[
@@ -30,10 +42,17 @@ def run_git(root: Path, *args: str) -> None:
"user.email=test@example.invalid",
"-c",
"user.name=test",
"-c",
"gc.auto=0",
"-c",
"core.fsmonitor=false",
"-c",
"maintenance.auto=false",
*args,
],
check=True,
capture_output=True,
env=GIT_ENV,
)
@@ -59,7 +78,7 @@ class DocsMirrorMappingTest(unittest.TestCase):
class DocsMirrorStagedTest(unittest.TestCase):
def setUp(self) -> None:
self._temporary = tempfile.TemporaryDirectory()
self._temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self._temporary.cleanup)
self.root = Path(self._temporary.name)
run_git(self.root, "init", "-q")
@@ -139,7 +158,7 @@ class DocsMirrorStagedTest(unittest.TestCase):
class DocsMirrorWorktreeTest(unittest.TestCase):
def test_worktree_drift_and_sync(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temporary:
root = Path(temporary)
write(root, "docs/GUIDE.md", "# Guide v2\n")
write(root, "frontend-modern/public/docs/GUIDE.md", "# Guide v1\n")
+7 -3
View File
@@ -63,11 +63,15 @@ test.describe.serial('Core E2E flows', () => {
test.skip(true, 'Virtualization Hosts section not present (nodes not in unified resources)');
}
const sectionToggle = proxmoxNodesHeading.locator('xpath=ancestor::button[1]');
// The section heading wraps its toggle button; the toggle's
// aria-controls names the collapsible body that holds the rows.
const sectionToggle = proxmoxNodesHeading.getByRole('button');
if ((await sectionToggle.getAttribute('aria-expanded')) === 'false') {
await sectionToggle.click();
}
const proxmoxNodesSection = sectionToggle.locator('..');
const proxmoxNodesSection = page.locator(
`#${await sectionToggle.getAttribute('aria-controls')}`,
);
const globalDefaultsContainer = proxmoxNodesSection
.getByText('Global Defaults', { exact: true })
.first()
@@ -206,7 +210,7 @@ test.describe.serial('Core E2E flows', () => {
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
const sectionToggleAfterReload = page
.getByRole('heading', { name: 'Virtualization Hosts' })
.locator('xpath=ancestor::button[1]');
.getByRole('button');
if ((await sectionToggleAfterReload.getAttribute('aria-expanded')) === 'false') {
await sectionToggleAfterReload.click();
}
@@ -1,5 +1,11 @@
import { test, expect, type Page } from '@playwright/test';
import { ensureAuthenticated, getMockMode, setMockMode, waitForPulseReady } from './helpers';
import {
ensureAuthenticated,
getMockMode,
primaryNavigationLink,
setMockMode,
waitForPulseReady,
} from './helpers';
const truthy = (value: string | undefined) => {
if (!value) return false;
@@ -91,7 +97,7 @@ const measureTabTransition = async (
waitForReady: (page: Page) => Promise<void>,
): Promise<number> => {
const start = Date.now();
await page.getByRole('tab', { name: new RegExp(`^${tabName}$`) }).first().click();
await primaryNavigationLink(page, new RegExp(`^${tabName}$`)).first().click();
await waitForReady(page);
return Date.now() - start;
};
+18 -25
View File
@@ -85,7 +85,7 @@ test.describe("Mobile viewport flows", () => {
await page.goto("/infrastructure");
await expect(page.locator("#root")).toBeVisible();
const bottomNav = page.getByRole("tablist", { name: "Mobile navigation" });
const bottomNav = page.getByRole("navigation", { name: "Mobile navigation" });
// Wait for the nav to mount before evaluating (evaluateAll does not auto-wait;
// WebKit can be slower to render SolidJS components than Chromium).
@@ -116,7 +116,7 @@ test.describe("Mobile viewport flows", () => {
await page.goto("/infrastructure");
await expect(page.locator("#root")).toBeVisible();
const nav = page.getByRole("tablist", { name: "Mobile navigation" });
const nav = page.getByRole("navigation", { name: "Mobile navigation" });
await expect(nav).toBeVisible();
// Verify the safe-area CSS class is applied to the nav. The computed padding-bottom
@@ -200,52 +200,45 @@ test.describe("Mobile viewport flows", () => {
await expect(table.locator("xpath=..")).toHaveClass(/overflow-x-auto/);
});
test("utility destinations stay pinned beside the scrollable platform rail", async ({
test("utility destinations stay pinned in the fixed rail", async ({
page,
}) => {
await page.goto("/proxmox/overview");
const primaryRail = page.locator('[data-mobile-nav-rail="primary"]');
const utilityRail = page.locator('[data-mobile-nav-rail="utility"]');
await expect(primaryRail).toBeVisible({ timeout: 30_000 });
await expect(utilityRail).toBeVisible({ timeout: 30_000 });
// The mobile rail no longer scrolls a platform strip: one fixed rail holds
// the platform switcher, the pinned utility destinations, and the More
// trigger for everything else, so the whole rail must fit the viewport.
const rail = page.locator('[data-mobile-nav-rail="fixed"]');
await expect(rail).toBeVisible({ timeout: 30_000 });
await expect
.poll(
() =>
page.evaluate(() => {
const element = document.querySelector<HTMLElement>(
'[data-mobile-nav-rail="primary"]',
'[data-mobile-nav-rail="fixed"]',
);
if (!element) return null;
return {
hasScrollableOverflow: ["auto", "scroll"].includes(
window.getComputedStyle(element).overflowX,
),
preservesRailWidth: element.scrollWidth >= element.clientWidth,
};
return element.scrollWidth <= element.clientWidth + 1;
}),
{ timeout: 30_000 },
)
.toEqual({
hasScrollableOverflow: true,
preservesRailWidth: true,
});
.toBe(true);
const viewportWidth = await getViewportWidth(page);
for (const tabId of ["alerts", "ai", "settings"]) {
const button = utilityRail.locator(`button[data-tab-id="${tabId}"]`);
await expect(button).toBeVisible();
let box = await button.boundingBox();
for (const tabId of ["platform-switcher", "alerts", "ai", "more"]) {
const destination = rail.locator(`[data-tab-id="${tabId}"]`);
await expect(destination).toBeVisible();
let box = await destination.boundingBox();
await expect
.poll(async () => {
box = await button.boundingBox();
box = await destination.boundingBox();
return box;
})
.not.toBeNull();
expect(
box,
`${tabId} utility destination should have a layout box`,
`${tabId} destination should have a layout box`,
).toBeTruthy();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
viewportWidth + 1,
@@ -421,7 +414,7 @@ test.describe("Mobile viewport flows", () => {
await page.goto("/infrastructure");
await expect(page.locator("#root")).toBeVisible();
const nav = page.getByRole("tablist", { name: "Mobile navigation" });
const nav = page.getByRole("navigation", { name: "Mobile navigation" });
await expect(nav).toBeVisible();
const aiButton = page.getByRole("button", {
@@ -75,7 +75,7 @@ async function stabilizeVisualState(page: Page): Promise<void> {
[data-testid="update-banner"] {
display: none !important;
}
[role="tab"] span[class*="rounded-full"] {
nav[aria-label="Primary navigation"] a span[class*="rounded-full"] {
display: none !important;
}
.tabs [role="group"][aria-label="Infrastructure"] {
@@ -4,6 +4,7 @@ import {
ensureFirstRunExperience,
navigateToSettings,
apiRequest,
primaryNavigationLink,
trackBrowserRequests,
waitForPulseReady,
} from "./helpers";
@@ -108,11 +109,8 @@ test.describe.serial("First-session experience", () => {
await ensureAuthenticated(page);
// The Settings tab is rendered as a div[role="tab"] in the top utility bar.
const settingsTab = page
.locator('[role="tab"]')
.filter({ hasText: "Settings" })
.first();
// Settings is a link in the primary navigation's utility group.
const settingsTab = primaryNavigationLink(page, "Settings");
await expect(
settingsTab,
"Settings tab should be visible in the top nav bar",
@@ -1,13 +1,10 @@
import { expect, test, type Page } from "@playwright/test";
import { ensureAuthenticated } from "./helpers";
import { ensureAuthenticated, primaryNavigationLink } from "./helpers";
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
async function openProxmoxBackups(page: Page) {
const proxmoxTab = page.getByRole("tab", {
name: "Proxmox",
exact: true,
});
const proxmoxTab = primaryNavigationLink(page, "Proxmox");
await expect(proxmoxTab).toBeVisible({ timeout: 30_000 });
await proxmoxTab.click();
@@ -1,6 +1,11 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { apiRequest, ensureAuthenticated, trackBrowserRequests } from "./helpers";
import {
apiRequest,
ensureAuthenticated,
primaryNavigationLink,
trackBrowserRequests,
} from "./helpers";
const PATROL_BLOCK_REASON =
"Connect a provider to power Pulse Assistant and Patrol.";
@@ -580,7 +585,7 @@ test.describe("Patrol runtime-state browser contract", () => {
entitlementsRequests.clear();
await mockBlockedPatrolRuntimeState(page);
await page.getByRole("tab", { name: "Patrol" }).click();
await primaryNavigationLink(page, /Patrol/).click();
await expect(page).toHaveURL(/\/patrol/);
// Banner and badge both use sentence case now.
@@ -2,7 +2,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test as base, type Page } from '@playwright/test';
import { createAuthenticatedStorageState, ensureAuthenticated, trackBrowserRequests } from './helpers';
import {
createAuthenticatedStorageState,
ensureAuthenticated,
primaryNavigationLink,
trackBrowserRequests,
} from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -101,10 +106,10 @@ async function getVisibleSettingsNavigation(
projectName: string,
) {
if (projectName.startsWith('mobile-')) {
await page
.getByRole('tablist', { name: 'Mobile navigation' })
.getByRole('button', { name: 'Settings', exact: true })
.click();
// Settings lives in the mobile rail's overflow menu, behind More navigation.
const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' });
await mobileNavigation.getByRole('button', { name: 'More navigation' }).click();
await mobileNavigation.getByRole('menuitem', { name: /^Settings/ }).click();
const settingsDrawerButton = page
.getByRole('main')
.getByRole('button', { name: 'Settings', exact: true });
@@ -355,10 +360,7 @@ base.describe('Demo mode commercial boundary', () => {
await expect(page.getByText('Pro Trial:', { exact: false })).toHaveCount(0);
await expect(page.getByText('Monitored systems: 16/5', { exact: true })).toHaveCount(0);
await expect(
page
.locator('[role="tab"]')
.filter({ hasText: 'Settings' })
.getByText('Pro', { exact: true }),
primaryNavigationLink(page, 'Settings').getByText('Pro', { exact: true }),
).toHaveCount(0);
expect(licenseStatusRequests, 'demo settings route should not read license status').toBe(0);
@@ -473,10 +475,7 @@ base.describe('Managed demo runtime commercial boundary', () => {
await expect(page.getByText('Pro Trial:', { exact: false })).toHaveCount(0);
await expect(page.getByText(/Monitored systems:\s*\d+\/\d+/)).toHaveCount(0);
await expect(
page
.locator('[role="tab"]')
.filter({ hasText: 'Settings' })
.getByText('Pro', { exact: true }),
primaryNavigationLink(page, 'Settings').getByText('Pro', { exact: true }),
).toHaveCount(0);
expect(
@@ -272,6 +272,15 @@ test.describe('Ceph alert thresholds', () => {
await expect(page).toHaveURL(/\/alerts\/thresholds\/infrastructure/);
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Storage Devices' })).toBeVisible();
// Threshold sections open collapsed; the collapsed body is inert and
// aria-hidden, so expand it before asserting on the rows it contains.
const storageToggle = page
.getByRole('heading', { name: 'Storage Devices' })
.getByRole('button');
if ((await storageToggle.getAttribute('aria-expanded')) === 'false') {
await storageToggle.click();
}
await expect(storageToggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByText('ceph-pool', { exact: true })).toBeVisible();
await expect(page.getByText('data_replication', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Revert to defaults for ceph-pool' })).toBeVisible();
@@ -20,7 +20,9 @@ async function openPatrolRecords(page: Page) {
});
await expect(activityTab).toBeVisible({ timeout: 60_000 });
await activityTab.click();
const recordsButton = page.getByRole("button", { name: /Patrol records/ });
const recordsButton = page.getByRole("button", {
name: /Finding options and history/,
});
await expect(recordsButton).toBeVisible({ timeout: 60_000 });
await recordsButton.click();
}
@@ -619,13 +621,13 @@ test.describe("Patrol Assistant operator briefing", () => {
await openPatrolRecords(page);
await page.getByText("High CPU usage").click();
const findingReview = page.locator(
"#finding-finding-operator-briefing-details",
);
await findingReview.getByText("Manage", { exact: true }).click();
await findingReview
.getByRole("button", { name: "Open in Assistant" })
// The finding options control and its menu render on the finding card,
// outside the details region.
await page
.getByRole("button", { name: "Finding options for High CPU usage" })
.first()
.click();
await page.getByRole("button", { name: "Open in Assistant" }).click();
const assistantContext = page.getByLabel("Assistant context");
await expect(assistantContext).toBeVisible();
@@ -760,10 +762,11 @@ test.describe("Patrol Assistant operator briefing", () => {
"#finding-finding-operator-briefing-details",
);
await expect(queuedFinding.getByText("details unavailable")).toBeVisible();
await queuedFinding.getByText("Manage", { exact: true }).click();
await queuedFinding
.getByRole("button", { name: "Open in Assistant" })
await page
.getByRole("button", { name: "Finding options for High CPU usage" })
.first()
.click();
await page.getByRole("button", { name: "Open in Assistant" }).click();
const queuedAssistantContext = page.getByLabel("Assistant context");
await expect(queuedAssistantContext).toBeVisible();
@@ -792,10 +795,11 @@ test.describe("Patrol Assistant operator briefing", () => {
await expect(
expiredFinding.getByText("Action details unavailable"),
).toBeVisible();
await expiredFinding.getByText("Manage", { exact: true }).click();
await expiredFinding
.getByRole("button", { name: "Open in Assistant" })
await page
.getByRole("button", { name: "Finding options for High CPU usage" })
.first()
.click();
await page.getByRole("button", { name: "Open in Assistant" }).click();
const hydratedFindingAssistantContext =
page.getByLabel("Assistant context");
@@ -3,7 +3,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base, type Page } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
import {
createAuthenticatedStorageState,
primaryNavigation,
primaryNavigationLink,
} from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -653,14 +657,12 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
timeout: 30_000,
});
const desktopNav = page.getByRole("tablist", {
name: "Primary navigation",
});
const desktopNav = primaryNavigation(page);
await expect(
desktopNav.getByRole("tab", { name: "Proxmox" }),
desktopNav.getByRole("link", { name: "Proxmox" }),
).toBeVisible();
await expect(
desktopNav.getByRole("tab", {
desktopNav.getByRole("link", {
name: /Patrol: 1 (?:action awaits approval|active attention item)/,
}),
).toBeVisible();
@@ -710,7 +712,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await expect(page.getByText("No Patrol work waiting")).toHaveCount(0);
await expect(page.getByText("Next check scheduled")).toHaveCount(0);
await page.getByRole("tab", { name: "Patrol" }).click();
await primaryNavigationLink(page, /Patrol/).click();
await expect(page).toHaveURL(/\/patrol$/);
await expect(
page.getByRole("heading", { level: 1, name: "Patrol" }),
@@ -759,7 +761,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
).toHaveCount(0);
await expect(page.getByText("What Pulse checked")).toHaveCount(0);
await page.getByRole("tab", { name: /Patrol/ }).click();
await primaryNavigationLink(page, /Patrol/).click();
await expect(page).toHaveURL(/\/patrol$/);
await expect(
page.getByRole("heading", { level: 2, name: "1 decision needs you" }),
@@ -886,7 +888,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
).toBeVisible();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.getByRole("button", { name: /Finding options and history/ })
.click();
await expect(
page.getByText("Operating system updates need review").first(),
@@ -933,7 +935,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.getByRole("button", { name: /Finding options and history/ })
.click();
const cleanupTitle = page
.getByText("Downloaded package data is using needed space")
@@ -991,7 +993,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.getByRole("button", { name: /Finding options and history/ })
.click();
await page.getByRole("button", { name: "Resolved", exact: true }).click();
const resolvedTitle = page
@@ -630,7 +630,7 @@ test("APT history keeps update execution verification recovery and delayed recei
await expect(dialog.getByText("Confirmed by executing agent")).toBeVisible();
await expect(dialog.getByText("Source: Executing agent")).toBeVisible();
await expect(
dialog.getByText("Yes — fact only; no reboot was authorized"),
dialog.getByText("Yes — fact only. No reboot was authorized"),
).toBeVisible();
await expect(dialog.getByTestId("action-execution-truth")).toBeVisible();
await expect(dialog.getByTestId("action-verification-truth")).toBeVisible();
@@ -105,6 +105,10 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
// Scan the settled surface: the update banner slides in over 300ms and
// axe would otherwise sample its controls mid-fade and report blended
// colours as contrast failures.
await page.emulateMedia({ reducedMotion: "reduce" });
await page.route("**/api/actions?*", (route) =>
route.fulfill({
status: 200,
@@ -1,13 +1,10 @@
import { expect, test, type Page } from "@playwright/test";
import { ensureAuthenticated } from "./helpers";
import { ensureAuthenticated, primaryNavigationLink } from "./helpers";
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
async function openDesktopProxmoxBackups(page: Page) {
const proxmoxTab = page.getByRole("tab", {
name: "Proxmox",
exact: true,
});
const proxmoxTab = primaryNavigationLink(page, "Proxmox");
await expect(proxmoxTab).toBeVisible({ timeout: 30_000 });
await proxmoxTab.click();
@@ -16,7 +13,7 @@ async function openDesktopProxmoxBackups(page: Page) {
});
await expect(sections).toBeVisible({ timeout: 60_000 });
await sections.getByRole("link", { name: "Backups", exact: true }).click();
await expect(page).toHaveURL(/\/proxmox\/backups$/);
await expect(page).toHaveURL(/\/proxmox\/backups\/date$/);
}
test.describe("Operational trust protection posture", () => {
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { primaryNavigationLink } from "./helpers";
type AttentionMode = "active" | "calm" | "failed";
@@ -112,7 +113,7 @@ test.beforeEach(async ({ page }) => {
).not.toBe("");
await expect(
page
.getByRole("tab", { name: "Patrol", exact: true })
.getByRole("link", { name: "Patrol", exact: true })
.or(page.getByRole("button", { name: "Patrol", exact: true }))
.first(),
).toBeVisible();
@@ -694,10 +695,8 @@ test("starts from the normal monitor shell and reaches the canonical attention q
(page.viewportSize()?.width ?? 1280) < 1024
? page
.getByRole("navigation", { name: "Mobile navigation" })
.getByRole("button", { name: /Patrol/ })
: page
.getByRole("tab", { name: /Patrol/ })
.or(page.getByRole("link", { name: /Patrol/ }));
.getByRole("link", { name: /Patrol/ })
: primaryNavigationLink(page, /Patrol/);
await patrolNavigation.click();
await expect(page).toHaveURL(/\/patrol/);
@@ -749,7 +748,7 @@ test("makes active operational work primary and preserves the evidence boundary"
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("tab", { name: "Patrol: 2 active attention items" }).or(
page.getByRole("link", { name: "Patrol: 2 active attention items" }).or(
page.getByRole("button", {
name: "Patrol: 2 active attention items",
}),
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test as base, expect, type Page, type Route } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
import { createAuthenticatedStorageState, primaryNavigation } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -590,7 +590,7 @@ test.describe("Operational trust availability resource facet", () => {
const queue = page.getByRole("region", { name: "Patrol decision inbox" });
await expect(queue).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("tab", {
primaryNavigation(page).getByRole("link", {
name: /Patrol: 1 (?:action awaits approval|active attention item)/,
}),
).toBeVisible();
+19
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import {
Browser,
Locator,
Page,
Request,
expect,
@@ -1402,6 +1403,24 @@ export async function getMockMode(page: Page) {
);
}
/**
* The desktop shell's primary navigation. Since the navigation became links
* (`<nav aria-label="Primary navigation">` with one `<a>` per destination),
* destinations are addressed by link role; the utility links carry their
* count in the accessible name ("Patrol: 2 active attention items"), so pass a
* regular expression when the count is not under test.
*/
export function primaryNavigation(page: Page): Locator {
return page.getByRole("navigation", { name: "Primary navigation" });
}
export function primaryNavigationLink(page: Page, name: string | RegExp): Locator {
return primaryNavigation(page).getByRole("link", {
name,
exact: typeof name === "string",
});
}
/**
* Navigate to settings page
*/
@@ -11,6 +11,8 @@ import {
setMockMode,
getMockMode,
trackBrowserRequests,
primaryNavigation,
primaryNavigationLink,
} from '../helpers';
/**
@@ -142,12 +144,12 @@ test.describe.serial('Journey: Bootstrap → Login → Infrastructure', () => {
await ensureJourneyReady(page);
// Wait for the primary navigation tablist to render before checking individual
// Wait for the primary navigation to render before checking individual
// tabs. After login redirect, the SPA may still be hydrating the layout.
// Target the specific aria-label to avoid matching other tablists (e.g. mobile nav).
// Target the specific aria-label to avoid matching other navigation (e.g. mobile nav).
await expect(
page.locator('[role="tablist"][aria-label="Primary navigation"]'),
'Primary navigation tablist should render',
primaryNavigation(page),
'Primary navigation should render',
).toBeVisible({ timeout: 15_000 });
// Core navigation tabs. The IA is platform-first: there is no single
@@ -161,7 +163,7 @@ test.describe.serial('Journey: Bootstrap → Login → Infrastructure', () => {
];
for (const tabName of expectedTabs) {
const tab = page.locator('[role="tab"]').filter({ hasText: tabName }).first();
const tab = primaryNavigationLink(page, new RegExp(tabName)).first();
await expect(
tab,
`Navigation tab "${tabName}" should be visible`,