Converge UI primitives and readiness guards

This commit is contained in:
rcourtman 2026-06-13 16:31:52 +01:00
parent 241ac9feaf
commit 09ed857869
28 changed files with 538 additions and 166 deletions

View file

@ -8,21 +8,24 @@
1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.6`.
2. That shipped prerelease tag resolves to commit `c25e95cb2b071551df95c8add62773905ba0628b`.
3. The governed release profile in `docs/release-control/control_plane.json`
3. The selected remote ref `origin/pulse/v6-release` is still behind the current
local governed branch state, so `Release Dry Run` would exercise stale remote
control-plane metadata instead of the intended candidate.
4. The governed release profile in `docs/release-control/control_plane.json`
currently declares both `prerelease_branch` and `stable_branch` as
`pulse/v6-release`.
4. The active control-plane target is still `v6-product-lane-expansion`, not
5. The active control-plane target is still `v6-product-lane-expansion`, not
`v6-ga-promotion`.
5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
local GA candidate exists on the governed stable line.
6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
non-publish `Release Dry Run` for the current `6.0.0` candidate.
7. `docs/releases/RELEASE_NOTES_v6.md` and
8. `docs/releases/RELEASE_NOTES_v6.md` and
`docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the
currently proposed exact dates for the eventual GA notice:
- `v6` GA date: `2026-06-04`
- `v5` end-of-support date: `2026-09-02`
8. There is still no governed `Release Dry Run` artifact or rehearsal record
9. There is still no governed `Release Dry Run` artifact or rehearsal record
exercising stable inputs for:
- `version=6.0.0`
- `promoted_from_tag=v6.0.0-rc.6`

View file

@ -99,6 +99,11 @@ notification, timeline, threshold ignored-prefix, and resource threshold note
editors must compose the shared `FormTextarea` primitive for label/id/help
wiring and textarea chrome instead of rendering raw native `<textarea>` shells
in alert-owned runtime components.
Alert resource threshold action presentation is also shared with
frontend-primitives: row, mobile-card, global-default, and bulk-selection
icon-only actions must compose `ActionIconButton` for shared size, tone, focus,
title, and accessible-name behavior instead of rendering local `<button>` plus
inline SVG shells in alert-owned runtime components.
## Extension Points

View file

@ -1453,6 +1453,12 @@ browser helper must treat `PLAYWRIGHT_BASE_URL` as the browser truth and leave
`PULSE_BASE_URL` available for backend-oriented health checks and setup
traffic, so split browser/backend proof can target fresh frontend code without
rewiring the API-side contract.
The Playwright managed-local-backend harness is part of that same canonical
integration runtime boundary. `tests/integration/scripts/managed-local-backend.mjs`
must seed a per-run audit signing key for local proof startup, while honoring
explicit `PULSE_AUDIT_SIGNING_KEY` or deterministic `PULSE_E2E_AUDIT_SIGNING_KEY`
overrides, so the runtime audit logger can remain fail-closed without breaking
managed local backend tests.
That same integration-README ownership includes the retired local commercial
trial probe guidance. The snapshot-clean trial instructions for
`tests/integration/scripts/retired-trial-acquisition-contract.sh` must describe

View file

@ -510,6 +510,12 @@ not a replacement status card, CTA band, or page-local nested card.
and reporting exports use the shared `success`, `successOutline`, and
`successGhost` Button variants instead of carrying page-local emerald action
shells.
Compact icon-only row, inline, and floating action controls belong to
`ActionIconButton`. Feature surfaces may own the icon choice, click handler,
label text, and layout slot, but icon-button size, tone, focus ring,
disabled treatment, title fallback, and accessible name wiring must come
from that shared primitive rather than page-local `<button>` plus inline SVG
shells.
Settings selection helpers such as `ResourcePicker` must use the same
`Button` primitive for select-all, clear, and chip remove actions instead of
restoring footer-local action shells.

View file

@ -3033,6 +3033,7 @@
"tests/integration/QUICK_START.md",
"tests/integration/README.md",
"tests/integration/scripts/managed-dev-runtime.mjs",
"tests/integration/scripts/managed-local-backend.mjs",
"tests/integration/tests/helpers.ts",
"tests/integration/tests/runtime-defaults.ts"
],
@ -3044,6 +3045,7 @@
"scripts/tests/test-hot-dev-bg.sh",
"scripts/tests/test-hot-dev-runtime.sh",
"scripts/tests/test-toggle-mock.sh",
"tests/integration/scripts/managed-local-backend.test.mjs",
"tests/integration/tests/16-dev-runtime-recovery.spec.ts"
]
},

View file

@ -520,6 +520,58 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "action-icon-button-shell",
"category": "action-button",
"summary": "Compact icon-only row and inline actions must compose ActionIconButton so size, tone, focus, disabled, title, aria-label, and icon behavior stay canonical instead of recreating page-local button and SVG shells.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"requiredConsumers": [
{ "path": "src/components/Alerts/AlertResourceTableDesktop.tsx" },
{ "path": "src/components/Alerts/AlertResourceTableMobile.tsx" },
{ "path": "src/components/Alerts/AlertResourceTableRow.tsx" },
{ "path": "src/components/Alerts/ResourceTable.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/components/Alerts/AlertResourceTableDesktop.tsx",
"patterns": ["<svg", "class=\"p-1 hover:text-muted", "class=\"p-1 text-red-600"]
},
{
"path": "src/components/Alerts/AlertResourceTableMobile.tsx",
"patterns": [
"<svg",
"class=\"p-1.5 bg-blue-50",
"class=\"p-1.5 bg-surface-hover",
"class=\"p-1.5 bg-green-50"
]
},
{
"path": "src/components/Alerts/AlertResourceTableRow.tsx",
"patterns": [
"<svg",
"class=\"p-1 hover:text-muted",
"class=\"p-1 text-blue-600",
"class=\"p-1 hover:text-base-content"
]
},
{
"path": "src/components/Alerts/ResourceTable.tsx",
"patterns": [
"<svg",
"text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/Alerts/ResourceTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "copy-value-action-shell",
"category": "copy-action",
@ -2231,6 +2283,31 @@
}
],
"patternGuards": [
{
"id": "alert-resource-action-local-svg-button-shell",
"category": "action-button",
"summary": "Alert resource threshold action controls must not recreate icon-only buttons with inline SVGs locally; compose ActionIconButton and lucide icons for row, mobile, and global actions.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/Alerts"],
"extensions": [".tsx"],
"allPatterns": ["<button", "<svg", "Edit thresholds"],
"legacyReason": "Retired migration debt. Alert resource threshold action buttons compose ActionIconButton for shared size, tone, focus, and accessible label behavior.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/Alerts/ResourceTable.test.tsx",
"src/components/shared/Button.test.tsx",
"src/components/shared/SharedPrimitives.guardrails.test.ts"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/Alerts/ResourceTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "patrol-ai-local-loading-spinner-shell",
"category": "loading-indicator",

View file

@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
@ -9,6 +9,20 @@ import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
const appStylesSource = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
const headerAuditSource = readFileSync(join(process.cwd(), 'scripts/header-audit.mjs'), 'utf8');
const integrationTestsDir = join(process.cwd(), '..', 'tests', 'integration', 'tests');
function readIntegrationTestSources(dir: string): Array<{ path: string; source: string }> {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return readIntegrationTestSources(path);
}
if (!entry.isFile() || !path.endsWith('.ts')) {
return [];
}
return [{ path, source: readFileSync(path, 'utf8') }];
});
}
describe('App architecture', () => {
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
@ -175,7 +189,9 @@ describe('App architecture', () => {
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
expect(appSource).toContain('window.setTimeout(() => {');
expect(appSource).toContain("closest('[data-ai-model-picker]')");
expect(appSource).toContain('if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {');
expect(appSource).toContain(
'if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {',
);
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
expect(appSource).not.toContain('TrialBanner');
@ -183,6 +199,21 @@ describe('App architecture', () => {
expect(appSource).not.toContain('monitoredSystemLimitWarningBanner');
});
it('keeps integration browser proofs off the retired AI route', () => {
const routesSource = readFileSync(join(integrationTestsDir, 'routes.ts'), 'utf8');
const retiredRouteNavigations = readIntegrationTestSources(integrationTestsDir).flatMap(
({ path, source }) =>
source
.split('\n')
.flatMap((line, index) =>
/page\.goto\(\s*(['"])\/ai\1/.test(line) ? [`${path}:${index + 1}`] : [],
),
);
expect(routesSource).toContain('export const PATROL_ROUTE = "/patrol";');
expect(retiredRouteNavigations).toEqual([]);
});
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)');
expect(appLayoutSource).toContain(

View file

@ -1,5 +1,8 @@
import { For, Show, createEffect } from 'solid-js';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import Trash2 from 'lucide-solid/icons/trash-2';
import { ActionIconButton } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
@ -331,53 +334,31 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
typeof props.table.onMetricDelayChange === 'function'
}
>
<button
type="button"
<ActionIconButton
onClick={() => props.setShowDelayRow(!props.showDelayRow())}
class="p-1 hover:text-muted transition-colors"
title={
props.showDelayRow()
? 'Hide alert delay settings'
: 'Show alert delay settings'
}
aria-label={
label={
props.showDelayRow()
? 'Hide alert delay settings'
: 'Show alert delay settings'
}
tone="muted"
size="xs"
>
<svg
<ChevronDown
class={`w-4 h-4 transition-transform ${props.showDelayRow() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
aria-hidden="true"
/>
</ActionIconButton>
</Show>
<Show when={props.hasCustomGlobalDefaults() && props.table.onResetDefaults}>
<button
type="button"
<ActionIconButton
onClick={() => props.table.onResetDefaults?.()}
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title={getAlertResourceTableResetFactoryDefaultsLabel()}
aria-label={getAlertResourceTableResetFactoryDefaultsLabel()}
label={getAlertResourceTableResetFactoryDefaultsLabel()}
tone="danger"
size="xs"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={!props.table.showDelayColumn && !props.hasCustomGlobalDefaults()}>
<span class="text-sm text-slate-400" aria-hidden="true">

View file

@ -1,6 +1,10 @@
import { For, Show } from 'solid-js';
import Check from 'lucide-solid/icons/check';
import Pencil from 'lucide-solid/icons/pencil';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import X from 'lucide-solid/icons/x';
import { ActionIconButton } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { TogglePrimitive } from '@/components/shared/Toggle';
@ -254,68 +258,32 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
<div class="flex gap-1 shrink-0">
<Show when={!isEditing() && resource.type !== 'dockerHost'}>
<button
type="button"
<ActionIconButton
onClick={() => startEditing(resource)}
class="p-1.5 bg-blue-50 dark:bg-blue-900 text-blue-600 rounded"
aria-label={`Edit thresholds for ${getAlertResourceLabel(resource)}`}
label={`Edit thresholds for ${getAlertResourceLabel(resource)}`}
tone="accent"
size="sm"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<Pencil class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={isEditing()}>
<button
type="button"
<ActionIconButton
onClick={cancelEditing}
class="p-1.5 bg-surface-hover text-slate-600 rounded"
aria-label="Cancel threshold edits"
label="Cancel threshold edits"
tone="muted"
size="sm"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<button
type="button"
<X class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
<ActionIconButton
onClick={() => saveEditing(resource.id)}
class="p-1.5 bg-green-50 dark:bg-green-900 text-green-600 rounded"
aria-label={`Save threshold edits for ${getAlertResourceLabel(resource)}`}
label={`Save threshold edits for ${getAlertResourceLabel(resource)}`}
tone="success"
size="sm"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
<Check class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show
when={
@ -323,15 +291,15 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
(resource.type === 'agent' && resource.disableConnectivity)
}
>
<button
type="button"
<ActionIconButton
onClick={() => props.table.onRemoveOverride(resource.id)}
class="p-1.5 bg-surface-alt hover:text-muted rounded transition-colors"
aria-label={`Revert to defaults for ${getAlertResourceLabel(resource)}`}
label={`Revert to defaults for ${getAlertResourceLabel(resource)}`}
title={getAlertResourceTableRevertToDefaultsLabel()}
tone="neutral"
size="sm"
>
<RotateCcw class="w-4 h-4" />
</button>
<RotateCcw class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
</div>
</div>

View file

@ -1,6 +1,9 @@
import { For, Show } from 'solid-js';
import Pencil from 'lucide-solid/icons/pencil';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import X from 'lucide-solid/icons/x';
import { ActionIconButton } from '@/components/shared/Button';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
@ -563,22 +566,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
<Show
when={!isEditing()}
fallback={
<button
type="button"
<ActionIconButton
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
label="Cancel editing"
tone="muted"
size="xs"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<X class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
}
>
{(() => {
@ -599,33 +594,26 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
}
>
<Show when={showEdit}>
<button
type="button"
<ActionIconButton
onClick={() => startEditing()}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
label={`Edit thresholds for ${resourceLabel()}`}
title="Edit thresholds"
aria-label={`Edit thresholds for ${resourceLabel()}`}
tone="accent"
size="xs"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<Pencil class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={showRevert}>
<button
type="button"
<ActionIconButton
onClick={() => props.onRemoveOverride(props.resource.id)}
class="p-1 hover:text-base-content transition-colors"
label={`Revert to defaults for ${resourceLabel()}`}
title={getAlertResourceTableRevertToDefaultsLabel()}
aria-label={`Revert to defaults for ${resourceLabel()}`}
tone="neutral"
size="xs"
>
<RotateCcw class="w-4 h-4" />
</button>
<RotateCcw class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
</Show>
);

View file

@ -114,6 +114,26 @@ vi.mock('lucide-solid/icons/rotate-ccw', () => ({
default: (props: any) => <svg data-testid="rotate-ccw-icon" class={props.class} />,
}));
vi.mock('lucide-solid/icons/check', () => ({
default: (props: any) => <svg data-testid="check-icon" class={props.class} />,
}));
vi.mock('lucide-solid/icons/chevron-down', () => ({
default: (props: any) => <svg data-testid="chevron-down-icon" class={props.class} />,
}));
vi.mock('lucide-solid/icons/pencil', () => ({
default: (props: any) => <svg data-testid="pencil-icon" class={props.class} />,
}));
vi.mock('lucide-solid/icons/trash-2', () => ({
default: (props: any) => <svg data-testid="trash-2-icon" class={props.class} />,
}));
vi.mock('lucide-solid/icons/x', () => ({
default: (props: any) => <svg data-testid="x-icon" class={props.class} />,
}));
import { ResourceTable, type Resource } from './ResourceTable';
// --- Helpers ---
@ -276,9 +296,13 @@ describe('ResourceTable', () => {
describe('table ownership model', () => {
it('keeps table state and metric rules in dedicated owners', () => {
const rawSvgTag = ['<', 'svg'].join('');
expect(resourceTableSource).toContain('useAlertResourceTableState');
expect(resourceTableSource).toContain('AlertResourceTableDesktop');
expect(resourceTableSource).toContain('AlertResourceTableMobile');
expect(resourceTableSource).toContain('ActionIconButton');
expect(resourceTableSource).not.toContain(rawSvgTag);
expect(resourceTableSource).not.toContain('const flattenResources = (): Resource[] => {');
expect(resourceTableSource).not.toContain(
'const normalizeMetricKey = (column: string): string => {',
@ -291,15 +315,21 @@ describe('ResourceTable', () => {
);
expect(alertResourceTableDesktopSource).toContain('AlertResourceTableRow');
expect(alertResourceTableDesktopSource).toContain('AlertResourceGroupHeader');
expect(alertResourceTableDesktopSource).toContain('ActionIconButton');
expect(alertResourceTableDesktopSource).not.toContain(rawSvgTag);
expect(alertResourceTableMobileSource).toContain('export function AlertResourceTableMobile');
expect(alertResourceTableMobileSource).toContain('AlertResourceGroupHeader');
expect(alertResourceTableMobileSource).toContain('FormTextarea');
expect(alertResourceTableMobileSource).toContain('ActionIconButton');
expect(alertResourceTableMobileSource).not.toContain(['<', 'textarea'].join(''));
expect(alertResourceTableMobileSource).not.toContain(rawSvgTag);
expect(alertResourceGroupHeaderSource).toContain('export function AlertResourceGroupHeader');
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
expect(alertResourceTableRowSource).toContain('FormTextarea');
expect(alertResourceTableRowSource).toContain('ActionIconButton');
expect(alertResourceTableRowSource).not.toContain(['<', 'textarea'].join(''));
expect(alertResourceTableRowSource).not.toContain(rawSvgTag);
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
expect(alertResourceTableModelSource).toContain(
'export function normalizeAlertResourceMetricKey',

View file

@ -1,10 +1,12 @@
import { Show } from 'solid-js';
import X from 'lucide-solid/icons/x';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import type { Alert } from '@/types/api';
import {
ALERT_BULK_EDIT_CLEAR_LABEL,
getAlertBulkEditOpenLabel,
} from '@/utils/alertBulkEditPresentation';
import { ActionIconButton } from '@/components/shared/Button';
import { useAlertResourceTableState } from './useAlertResourceTableState';
import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
import { AlertResourceTableDesktop } from './AlertResourceTableDesktop';
@ -135,22 +137,15 @@ export function ResourceTable(props: ResourceTableProps) {
>
{getAlertBulkEditOpenLabel()}
</button>
<button
type="button"
class="text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5 transition-colors focus:outline-none"
<ActionIconButton
class="rounded-full bg-surface text-slate-400 hover:bg-slate-700 hover:text-white focus-visible:ring-offset-0"
onClick={clearSelectedIds}
aria-label={ALERT_BULK_EDIT_CLEAR_LABEL}
title={ALERT_BULK_EDIT_CLEAR_LABEL}
label={ALERT_BULK_EDIT_CLEAR_LABEL}
size="sm"
tone="neutral"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<X class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</div>
</div>
</Show>

View file

@ -2,6 +2,7 @@ import { Route, Router } from '@solidjs/router';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ActionIconButton,
Button,
ButtonLink,
CommandCopyButton,
@ -27,6 +28,10 @@ describe('Button', () => {
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_VARIANT_CLASSES');
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain('getCopyValueButtonClass');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_TONE_CLASSES');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain('getActionIconButtonClass');
expect(buttonSource).toContain('export function ActionIconButton');
expect(buttonModelSource).toContain(
"secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover'",
);
@ -194,6 +199,26 @@ describe('Button', () => {
expect(screen.getByRole('button', { name: 'Copy blank' })).toBeDisabled();
});
it('renders compact action icon buttons through the shared primitive', () => {
const onClick = vi.fn();
render(() => (
<ActionIconButton label="Edit thresholds" tone="accent" size="xs" onClick={onClick}>
<span aria-hidden="true">E</span>
</ActionIconButton>
));
const button = screen.getByRole('button', { name: 'Edit thresholds' });
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('title', 'Edit thresholds');
expect(button).toHaveClass('h-6');
expect(button).toHaveClass('w-6');
expect(button).toHaveClass('bg-blue-50');
button.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders drawer header actions through the shared button family', () => {
const onAsk = vi.fn();
const onClose = vi.fn();

View file

@ -3,11 +3,14 @@ import CheckIcon from 'lucide-solid/icons/check';
import CopyIcon from 'lucide-solid/icons/copy';
import { JSX, Show, mergeProps, splitProps } from 'solid-js';
import {
getActionIconButtonClass,
getButtonClass,
getCopyValueButtonClass,
getDrawerHeaderActionButtonClass,
getDrawerHeaderActionGroupClass,
getDrawerHeaderIconButtonClass,
type ActionIconButtonSize,
type ActionIconButtonTone,
type ButtonSize,
type ButtonVariant,
type CopyValueButtonSize,
@ -51,6 +54,17 @@ export interface CopyValueButtonProps extends Omit<
children?: JSX.Element;
}
export interface ActionIconButtonProps extends Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> {
label: string;
tone?: ActionIconButtonTone;
size?: ActionIconButtonSize;
class?: string;
children: JSX.Element;
}
export interface DrawerHeaderActionGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
class?: string;
children?: JSX.Element;
@ -217,6 +231,41 @@ export function CopyValueButton(props: CopyValueButtonProps) {
);
}
export function ActionIconButton(props: ActionIconButtonProps) {
const merged = mergeProps(
{
tone: 'neutral' as ActionIconButtonTone,
size: 'sm' as ActionIconButtonSize,
type: 'button' as const,
},
props,
);
const [local, rest] = splitProps(merged, [
'label',
'tone',
'size',
'class',
'children',
'title',
'aria-label',
]);
return (
<button
{...rest}
class={getActionIconButtonClass({
tone: local.tone,
size: local.size,
class: local.class,
})}
title={local.title ?? local.label}
aria-label={local['aria-label'] ?? local.label}
>
{local.children}
</button>
);
}
export function ButtonLink(props: ButtonLinkProps) {
const merged = mergeProps(
{ variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize },

View file

@ -201,6 +201,7 @@ import diskListSource from '@/components/Storage/DiskList.tsx?raw';
import alertOverviewStatsCardsSource from '@/features/alerts/AlertOverviewStatsCards.tsx?raw';
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
import alertResourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTableDesktop.tsx?raw';
import alertResourceTableMobileSource from '@/components/Alerts/AlertResourceTableMobile.tsx?raw';
import alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableRow.tsx?raw';
@ -2440,9 +2441,13 @@ describe('shared primitive guardrails', () => {
}>;
};
const registeredRule = registry.rules?.find((rule) => rule.id === 'button-command-shell');
const actionIconRule = registry.rules?.find((rule) => rule.id === 'action-icon-button-shell');
const registeredGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-secondary-command-local-shell',
);
const alertResourceActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'alert-resource-action-local-svg-button-shell',
);
const commandCopyGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-command-copy-local-shell',
);
@ -2792,6 +2797,61 @@ describe('shared primitive guardrails', () => {
expect(drawerHeaderIconGuard?.scopes).toEqual(['src/components', 'src/features', 'src/pages']);
expect(drawerHeaderIconGuard?.allowedPaths ?? []).toHaveLength(0);
expect(drawerHeaderIconGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
expect(actionIconRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(actionIconRule?.canonical?.export).toBe('ActionIconButton');
expect(actionIconRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/Alerts/AlertResourceTableDesktop.tsx',
'src/components/Alerts/AlertResourceTableMobile.tsx',
'src/components/Alerts/AlertResourceTableRow.tsx',
'src/components/Alerts/ResourceTable.tsx',
]);
expect(actionIconRule?.forbiddenPatterns).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'src/components/Alerts/AlertResourceTableDesktop.tsx',
patterns: expect.arrayContaining([
'<svg',
'class="p-1 hover:text-muted',
'class="p-1 text-red-600',
]),
}),
expect.objectContaining({
path: 'src/components/Alerts/AlertResourceTableMobile.tsx',
patterns: expect.arrayContaining([
'<svg',
'class="p-1.5 bg-blue-50',
'class="p-1.5 bg-surface-hover',
'class="p-1.5 bg-green-50',
]),
}),
expect.objectContaining({
path: 'src/components/Alerts/AlertResourceTableRow.tsx',
patterns: expect.arrayContaining([
'<svg',
'class="p-1 hover:text-muted',
'class="p-1 text-blue-600',
'class="p-1 hover:text-base-content',
]),
}),
expect.objectContaining({
path: 'src/components/Alerts/ResourceTable.tsx',
patterns: expect.arrayContaining([
'<svg',
'text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5',
]),
}),
]),
);
expect(alertResourceActionGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(alertResourceActionGuard?.canonical?.export).toBe('ActionIconButton');
expect(alertResourceActionGuard?.allPatterns).toEqual(['<button', '<svg', 'Edit thresholds']);
expect(alertResourceActionGuard?.scopes).toEqual(['src/components/Alerts']);
expect(alertResourceActionGuard?.allowedPaths ?? []).toHaveLength(0);
expect(alertResourceActionGuard?.ignoredPaths).toEqual([
'src/components/Alerts/ResourceTable.test.tsx',
'src/components/shared/Button.test.tsx',
'src/components/shared/SharedPrimitives.guardrails.test.ts',
]);
expect(copyValueRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(copyValueRule?.canonical?.export).toBe('CopyValueButton');
expect(copyValueRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
@ -2831,12 +2891,14 @@ describe('shared primitive guardrails', () => {
expect(buttonSource).toContain('export function Button');
expect(buttonSource).toContain('export function CommandCopyButton');
expect(buttonSource).toContain('export function CopyValueButton');
expect(buttonSource).toContain('export function ActionIconButton');
expect(buttonSource).toContain('export function DrawerHeaderActionButton');
expect(buttonSource).toContain('export function DrawerHeaderActionGroup');
expect(buttonSource).toContain('export function DrawerHeaderIconButton');
expect(buttonSource).toContain('export function ButtonLink');
expect(buttonSource).toContain('getButtonClass');
expect(buttonSource).toContain('getCopyValueButtonClass');
expect(buttonSource).toContain('getActionIconButtonClass');
expect(copyableCodeRowSource).toContain('CopyValueButton');
expect(buttonModelSource).toContain('BUTTON_VARIANT_CLASSES');
expect(buttonModelSource).toContain('BUTTON_SIZE_CLASSES');
@ -2848,11 +2910,23 @@ describe('shared primitive guardrails', () => {
expect(buttonModelSource).toContain('successGhost:');
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_VARIANT_CLASSES');
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_TONE_CLASSES');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain('dangerOutline:');
expect(buttonModelSource).toContain('settingsAction:');
expect(buttonModelSource).toContain('getCopyValueButtonClass');
expect(buttonModelSource).toContain('getActionIconButtonClass');
expect(buttonModelSource).toContain('getDrawerHeaderActionButtonClass');
expect(buttonModelSource).toContain('getDrawerHeaderIconButtonClass');
for (const source of [
alertResourceTableSource,
alertResourceTableDesktopSource,
alertResourceTableMobileSource,
alertResourceTableRowSource,
]) {
expect(source).toContain('ActionIconButton');
expect(source).not.toContain(['<', 'svg'].join(''));
}
for (const drawerSource of [guestDrawerSource, resourceDetailDrawerSource]) {
expect(drawerSource).toContain('@/components/shared/Button');
expect(drawerSource).toContain('DrawerHeaderActionGroup');

View file

@ -72,6 +72,8 @@ export const getButtonClass = (options: ButtonClassOptions = {}): string =>
export type CopyValueButtonVariant = 'neutral' | 'ghost' | 'accent' | 'chip';
export type CopyValueButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'chip';
export type ActionIconButtonTone = 'neutral' | 'muted' | 'accent' | 'success' | 'danger';
export type ActionIconButtonSize = 'xs' | 'sm' | 'md';
export const COPY_VALUE_BUTTON_BASE_CLASS =
'inline-flex shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
@ -108,6 +110,42 @@ export const getCopyValueButtonClass = (options: CopyValueButtonClassOptions = {
.filter(Boolean)
.join(' ');
export const ACTION_ICON_BUTTON_BASE_CLASS =
'inline-flex shrink-0 items-center justify-center rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50';
export const ACTION_ICON_BUTTON_TONE_CLASSES: Record<ActionIconButtonTone, string> = {
neutral: 'text-base-content hover:bg-surface-hover hover:text-base-content',
muted: 'text-muted hover:bg-surface-hover hover:text-base-content',
accent:
'bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:bg-blue-900 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300',
success:
'bg-green-50 text-green-600 hover:bg-green-100 hover:text-green-700 dark:bg-green-900 dark:text-green-400 dark:hover:bg-green-950 dark:hover:text-green-300',
danger:
'text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300',
};
export const ACTION_ICON_BUTTON_SIZE_CLASSES: Record<ActionIconButtonSize, string> = {
xs: 'h-6 w-6',
sm: 'h-7 w-7',
md: 'h-8 w-8',
};
export type ActionIconButtonClassOptions = {
tone?: ActionIconButtonTone;
size?: ActionIconButtonSize;
class?: string;
};
export const getActionIconButtonClass = (options: ActionIconButtonClassOptions = {}): string =>
[
ACTION_ICON_BUTTON_BASE_CLASS,
ACTION_ICON_BUTTON_TONE_CLASSES[options.tone ?? 'neutral'],
ACTION_ICON_BUTTON_SIZE_CLASSES[options.size ?? 'sm'],
options.class,
]
.filter(Boolean)
.join(' ');
export const DRAWER_HEADER_ACTION_GROUP_CLASS = 'flex shrink-0 items-center gap-1.5';
export const DRAWER_HEADER_ACTION_BUTTON_CLASS =

View file

@ -410,8 +410,8 @@ describe('platform overview layout guardrails', () => {
expect(truenasSystemsTableSource).toMatch(
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?System/,
);
expect(truenasSystemsTableSource).toContain(
'<span class="md:hidden">{formatPercent(storagePercent())}</span>',
expect(truenasSystemsTableSource).toMatch(
/<span class="md:hidden">\s*<PlatformTablePercentValue value={storagePercent\(\)} \/>\s*<\/span>/,
);
expect(vsphereHostsTableSource).toMatch(
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Host/,

View file

@ -208,7 +208,27 @@ def _staged_python_script_run(
env["PYTHONPATH"] = (
script_dir if not current_pythonpath else f"{script_dir}{os.pathsep}{current_pythonpath}"
)
return [interpreter, "-", *run[2:]], read_repo_text(rel, staged=True), env
script_path = str((REPO_ROOT / rel).resolve())
script_content = read_repo_text(rel, staged=True)
wrapper = (
"from pathlib import Path\n"
"import sys\n\n"
f"_script_path = {script_path!r}\n"
f"_script_content = {script_content!r}\n"
"_script_dir = str(Path(_script_path).parent)\n"
"if _script_dir not in sys.path:\n"
" sys.path.insert(0, _script_dir)\n"
"sys.argv = [_script_path, *sys.argv[1:]]\n"
"_globals = {\n"
" '__name__': '__main__',\n"
" '__file__': _script_path,\n"
" '__package__': None,\n"
" '__cached__': None,\n"
" '__spec__': None,\n"
"}\n"
"exec(compile(_script_content, _script_path, 'exec'), _globals)\n"
)
return [interpreter, "-", *run[2:]], wrapper, env
def run_selected_proof_commands(commands: list[dict[str, Any]], *, staged: bool = False) -> int:

View file

@ -292,6 +292,53 @@ class ReadinessAssertionGuardTest(unittest.TestCase):
self.assertEqual(exit_code, 0)
self.assertEqual((repo_root / "proof.out").read_text(encoding="utf-8"), "ok")
def test_staged_repo_python_script_preserves_file_and_argv_identity(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = Path(tmpdir)
self.git(repo_root, "init")
script_rel = "scripts/release_control/proof_script.py"
script_path = repo_root / script_rel
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(
"\n".join(
[
"from pathlib import Path",
"import sys",
"",
"Path('proof.out').write_text(",
" f'{Path(__file__).resolve()}\\n{sys.argv[0]}\\n{sys.argv[1]}',",
" encoding='utf-8',",
")",
"",
]
),
encoding="utf-8",
)
self.git(repo_root, "add", script_rel)
script_path.write_text("raise SystemExit(1)\n", encoding="utf-8")
commands = [
{
"assertion_id": "RA8",
"command_id": "staged-script",
"cwd": ".",
"run": ["python3", script_rel, "--flag"],
}
]
with mock.patch.object(readiness_assertion_guard, "REPO_ROOT", repo_root), mock.patch(
"repo_file_io.REPO_ROOT", repo_root
):
exit_code = readiness_assertion_guard.run_selected_proof_commands(commands, staged=True)
self.assertEqual(exit_code, 0)
self.assertEqual(
(repo_root / "proof.out").read_text(encoding="utf-8").splitlines(),
[str(script_path.resolve()), str(script_path.resolve()), "--flag"],
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,6 +1,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
import { withExclusiveLock } from '../../../scripts/exclusive-lock.mjs';
@ -155,6 +156,9 @@ export function buildManagedLocalBackendEnv(state, env = process.env) {
PULSE_PUBLIC_URL: state.baseURL,
PULSE_DATA_DIR: state.dataDir,
PULSE_AUDIT_DIR: state.dataDir,
PULSE_AUDIT_SIGNING_KEY: trim(env.PULSE_AUDIT_SIGNING_KEY)
|| trim(env.PULSE_E2E_AUDIT_SIGNING_KEY)
|| randomBytes(32).toString('hex'),
PULSE_METRICS_PORT: state.metricsPort,
PULSE_DEV: 'true',
PULSE_E2E_BILLING_STATE_PATH: state.billingStatePath,

View file

@ -71,6 +71,7 @@ test('buildManagedLocalBackendEnv seeds auth, bootstrap token, and billing path'
assert.equal(env.PULSE_DEV, 'true');
assert.equal(env.PULSE_DATA_DIR, state.dataDir);
assert.equal(env.PULSE_E2E_BILLING_STATE_PATH, state.billingStatePath);
assert.match(env.PULSE_AUDIT_SIGNING_KEY, /^[0-9a-f]{64}$/);
assert.equal(env.PULSE_E2E_BOOTSTRAP_TOKEN.length > 0, true);
assert.equal(env.PULSE_E2E_PRIMARY_API_TOKEN.length > 0, true);
assert.equal(env.PULSE_METRICS_PORT, '0');
@ -79,6 +80,28 @@ test('buildManagedLocalBackendEnv seeds auth, bootstrap token, and billing path'
assert.match(env.ALLOWED_ORIGINS, /5173/);
});
test('buildManagedLocalBackendEnv preserves explicit audit signing key', () => {
const state = buildManagedLocalBackendState({
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',
});
const env = buildManagedLocalBackendEnv(state, {
PULSE_AUDIT_SIGNING_KEY: 'explicit-audit-key',
});
assert.equal(env.PULSE_AUDIT_SIGNING_KEY, 'explicit-audit-key');
});
test('buildManagedLocalBackendEnv accepts deterministic e2e audit signing key', () => {
const state = buildManagedLocalBackendState({
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',
});
const env = buildManagedLocalBackendEnv(state, {
PULSE_E2E_AUDIT_SIGNING_KEY: 'e2e-audit-key',
});
assert.equal(env.PULSE_AUDIT_SIGNING_KEY, 'e2e-audit-key');
});
test('buildManagedLocalBackendEnv seeds deterministic auth in hosted mode', () => {
const state = buildManagedLocalBackendState({
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',

View file

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { apiRequest, ensureAuthenticated } from "./helpers";
import { PATROL_ROUTE } from "./routes";
/**
* V6 License Activation E2E Test
@ -298,7 +299,7 @@ test.describe.serial("V6 license activation flow", () => {
).toBeVisible();
await expect(page.getByText(/Patrol quickstart/i)).toHaveCount(0);
await page.goto("/ai");
await page.goto(PATROL_ROUTE);
await expect(
page.getByRole("heading", { name: "Patrol" }).first(),
).toBeVisible();

View file

@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { test as base, expect } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
import { PATROL_ROUTE } from "./routes";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -321,7 +322,7 @@ test.describe("TrueNAS patrol finding links", () => {
});
});
await page.goto("/ai", { waitUntil: "domcontentloaded" });
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();

View file

@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { test as base, expect } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
import { PATROL_ROUTE } from "./routes";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -252,7 +253,7 @@ test.describe("TrueNAS patrol run history", () => {
});
});
await page.goto("/ai", { waitUntil: "domcontentloaded" });
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
await page.getByRole("button", { name: "Runs" }).click();

View file

@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { ensureAuthenticated } from "./helpers";
import { PATROL_ROUTE } from "./routes";
type AISettingsPayload = {
enabled: boolean;
@ -379,7 +380,7 @@ test.describe("Retired quickstart browser contract", () => {
},
});
await page.goto("/ai", { waitUntil: "domcontentloaded" });
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
await expect(page.getByText("Patrol Paused").first()).toBeVisible();
await expect(

View file

@ -170,7 +170,7 @@ test.describe("Assistant & Patrol settings provider setup", () => {
expect(updateRequests[0]).not.toHaveProperty("model");
await expect(
page.getByText("Assistant & Patrol Model Overrides"),
page.getByRole("button", { name: "Model Overrides" }),
).toBeVisible();
const workloadDiscoveryToggle = page.getByRole("button", {
name: /workload discovery/i,
@ -179,10 +179,7 @@ test.describe("Assistant & Patrol settings provider setup", () => {
await workloadDiscoveryToggle.click();
await expect(page.getByText("Enable workload discovery")).toBeVisible();
await expect(
page.getByText(
"Workload discovery gives Pulse Assistant and Patrol concrete service context, so chat responses and verification findings can reference real services and commands instead of generic advice.",
{ exact: true },
),
page.getByRole("button", { name: "Run discovery now" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /enable assistant and patrol/i }),

View file

@ -86,9 +86,7 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await page.goto('/settings/security-roles');
await expect(
page.getByRole('heading', { name: 'Infrastructure', exact: true }),
).toBeVisible();
await expect(page.getByRole('heading', { level: 1, name: 'Roles' })).toBeVisible();
await expect(page.getByText('Custom Roles (Pro)')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Remote Access' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Roles' })).toHaveCount(0);

View file

@ -0,0 +1 @@
export const PATROL_ROUTE = "/patrol";