mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user