mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1842 from rcourtman/maintainer/20260902T030649Z
Make release checks reliable and dialogs accessible
This commit is contained in:
@@ -9,6 +9,10 @@ on:
|
||||
description: 'Version number (e.g., 4.30.0)'
|
||||
required: true
|
||||
type: string
|
||||
expected_source_sha:
|
||||
description: 'Exact 40-character commit SHA admitted for this release'
|
||||
required: true
|
||||
type: string
|
||||
release_notes:
|
||||
description: 'Release notes (markdown)'
|
||||
required: true
|
||||
@@ -108,6 +112,22 @@ jobs:
|
||||
visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }}
|
||||
visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }}
|
||||
steps:
|
||||
- name: Verify admitted source commit
|
||||
env:
|
||||
EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::expected_source_sha must be an exact 40-character commit SHA"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \
|
||||
"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then
|
||||
echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -2454,13 +2454,17 @@ func TestRun_DockerRetry(t *testing.T) {
|
||||
origDocker := newDockerAgent
|
||||
defer func() { newDockerAgent = origDocker }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
retrySucceeded := make(chan struct{})
|
||||
|
||||
// First call fails, second succeeds
|
||||
calls := 0
|
||||
var calls atomic.Int32
|
||||
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
if calls.Add(1) == 1 {
|
||||
return nil, errors.New("not available yet")
|
||||
}
|
||||
close(retrySucceeded)
|
||||
return &mockRunnableCloser{mockRunnable: mockRunnable{started: make(chan struct{})}}, nil
|
||||
}
|
||||
|
||||
@@ -2469,8 +2473,6 @@ func TestRun_DockerRetry(t *testing.T) {
|
||||
retryInitialDelay = 1 * time.Millisecond
|
||||
defer func() { retryInitialDelay = origInitial }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
@@ -2479,17 +2481,26 @@ func TestRun_DockerRetry(t *testing.T) {
|
||||
errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-docker=true", "-enable-host=false"}, func(s string) string { return "" })
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-retrySucceeded:
|
||||
cancel()
|
||||
case err := <-errCh:
|
||||
t.Fatalf("run returned before Docker retry succeeded: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("Docker retry did not succeed: %v", ctx.Err())
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for run")
|
||||
t.Fatal("timeout waiting for run to stop")
|
||||
}
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls to newDockerAgent, got %d", calls)
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("newDockerAgent calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2547,13 +2558,17 @@ func TestRun_KubeRetry(t *testing.T) {
|
||||
origKube := newKubeAgent
|
||||
defer func() { newKubeAgent = origKube }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
retrySucceeded := make(chan struct{})
|
||||
|
||||
// First call fails, second succeeds
|
||||
calls := 0
|
||||
var calls atomic.Int32
|
||||
newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
if calls.Add(1) == 1 {
|
||||
return nil, errors.New("not available yet")
|
||||
}
|
||||
close(retrySucceeded)
|
||||
return &mockRunnable{started: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
@@ -2562,8 +2577,6 @@ func TestRun_KubeRetry(t *testing.T) {
|
||||
retryInitialDelay = 1 * time.Millisecond
|
||||
defer func() { retryInitialDelay = origInitial }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
@@ -2573,17 +2586,26 @@ func TestRun_KubeRetry(t *testing.T) {
|
||||
errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-kubernetes=true", "-enable-host=false", "-enable-docker=false"}, func(s string) string { return "" })
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-retrySucceeded:
|
||||
cancel()
|
||||
case err := <-errCh:
|
||||
t.Fatalf("run returned before Kubernetes retry succeeded: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("Kubernetes retry did not succeed: %v", ctx.Err())
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for run")
|
||||
t.Fatal("timeout waiting for run to stop")
|
||||
}
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls to newKubeAgent, got %d", calls)
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("newKubeAgent calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2138,6 +2138,13 @@ artifact-selection behaviour.
|
||||
trigger, promotion resolver, rendered release body, current upgrade guide,
|
||||
or current release packet that routes systemd/LXC rollback through the
|
||||
Unified Agent installer, and must retain explicit Docker image guidance.
|
||||
17. Bind every publishing release dispatch to the exact commit admitted by the
|
||||
caller. `.github/workflows/create-release.yml` must require a full
|
||||
40-character `expected_source_sha` and, before checkout, reject the run
|
||||
unless both `GITHUB_SHA` and `GITHUB_WORKFLOW_SHA` equal that commit.
|
||||
`scripts/trigger-release.sh` and `scripts/trigger-stable-patch.sh` must send
|
||||
the exact remote candidate SHA they already verified; branch ancestry or a
|
||||
later branch tip is not equivalent release admission.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ overlay's accessible heading. A reusable panel may suppress its standalone
|
||||
title when the owning overlay supplies the canonical title, while preserving
|
||||
that title in inline and desktop contexts; the overlay remains responsible for
|
||||
one visible heading, its accessible label, dismissal, and focus return.
|
||||
The shared `Dialog` component requires exactly one accessible-name strategy at
|
||||
its component boundary: consumers provide either `ariaLabelledBy` for a visible
|
||||
heading or `ariaLabel` when no visible label is available. Unnamed dialogs and
|
||||
consumers that provide both strategies must fail the frontend type boundary.
|
||||
The alert schedule's initial-delivery selector composes `SettingsPanel` and
|
||||
`FormSelect`, uses the shared alert-configuration presentation vocabulary, and
|
||||
exposes the same email, webhook, Apprise, and all-destination labels used by
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "2f8a4ec629b75c7ecefd38a9abc4b1511bc9a891",
|
||||
"verified_at": "2026-09-01T19:54:19Z",
|
||||
"base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab",
|
||||
"verified_at": "2026-09-02T03:39:02Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
|
||||
"frontend-modern/src/features/home/HomePageSurface.tsx",
|
||||
"frontend-modern/src/features/home/homePageModel.ts",
|
||||
"frontend-modern/src/i18n/messages.de.ts",
|
||||
"frontend-modern/src/i18n/messages.es.ts",
|
||||
"frontend-modern/src/i18n/messages.ts",
|
||||
"frontend-modern/src/routing/navigation.ts",
|
||||
"frontend-modern/src/routing/resourceLinks.ts",
|
||||
"frontend-modern/src/routing/routePreload.ts",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts"
|
||||
],
|
||||
"changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/App.tsx": "d5473f7838148edeaaf5564f01c166d97b6086232dcba70f6296b5c58e563a4b",
|
||||
"frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04",
|
||||
"frontend-modern/src/features/home/HomePageSurface.tsx": "deleted",
|
||||
"frontend-modern/src/features/home/homePageModel.ts": "deleted",
|
||||
"frontend-modern/src/i18n/messages.de.ts": "602246d3d4ce11a1a8a914027d950a30ba1f2f25850c385836013853957c2d0c",
|
||||
"frontend-modern/src/i18n/messages.es.ts": "ed5a749603efad29293cd6964215f6edc21a4ce14bca99a48429ddd120770263",
|
||||
"frontend-modern/src/i18n/messages.ts": "43a757e00eaa7879c400895c9c59a930e31513dbf03e0ae6d60f7e72a6ad9962",
|
||||
"frontend-modern/src/routing/navigation.ts": "8ae1ad012e60ef345ffb3d18bb66f5d8af056758109655a8ec362ebf4c7b9556",
|
||||
"frontend-modern/src/routing/resourceLinks.ts": "dee9a426de785e23390ba49c9067f55c8f923cecfce1247ff18f9b1004e0cc90",
|
||||
"frontend-modern/src/routing/routePreload.ts": "ee79d423db0afcf8d76d1d39da59a13cb2c98e908e516a016cbb425982b25873",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts": "bfca19b4e183777ee1535073d48cce92ce31980f4105bab337f33a28538a6a35"
|
||||
"frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735"
|
||||
},
|
||||
"routes": [
|
||||
"/",
|
||||
"/home",
|
||||
"/proxmox/overview"
|
||||
"/settings/infrastructure",
|
||||
"/actions",
|
||||
"/alerts/overview",
|
||||
"/settings/system-general",
|
||||
"/patrol",
|
||||
"/"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -44,21 +21,25 @@
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 393,
|
||||
"height": 851
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"signed-in desktop shell with primary tabs Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines and no Home tab",
|
||||
"/home renders the Page Not Found surface with 'No route matched /home' and a Go to workspace button",
|
||||
"signed-in narrow shell on /proxmox/overview with the bottom navigation bar showing Proxmox, Alerts, Patrol, Actions, More and no Home entry",
|
||||
"More navigation sheet open at narrow width listing Settings only",
|
||||
"no horizontal page overflow at 375px; no console errors at either width"
|
||||
"Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow",
|
||||
"Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored",
|
||||
"authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations",
|
||||
"logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations"
|
||||
],
|
||||
"interactions": [
|
||||
"navigated to / and /home at desktop width and read the rendered nav and main headings",
|
||||
"pressed Go to workspace on the /home not-found page and landed on /proxmox/overview",
|
||||
"resized to 375x812, loaded /proxmox/overview, opened the More navigation sheet, closed it with Escape",
|
||||
"confirmed the default landing route and nav order are unchanged from the parent revision apart from the removed Home entry"
|
||||
"opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description",
|
||||
"inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout",
|
||||
"verified the dialog bounds stay inside both viewports and the document has no horizontal overflow",
|
||||
"dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure",
|
||||
"scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
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;
|
||||
@@ -730,6 +731,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
jump past the chrome straight into the page content. */}
|
||||
<a
|
||||
href="#main"
|
||||
onClick={() => mainContentEl?.focus()}
|
||||
onFocus={() => setSkipLinkFocused(true)}
|
||||
onBlur={() => setSkipLinkFocused(false)}
|
||||
class={
|
||||
@@ -814,7 +816,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.versionInfo()?.channel === 'rc'}>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-700 text-white rounded font-bold">
|
||||
Preview
|
||||
</span>
|
||||
</Show>
|
||||
@@ -980,7 +982,9 @@ 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"
|
||||
>
|
||||
<div class="pulse-panel">
|
||||
|
||||
@@ -174,7 +174,16 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(container.querySelector('.pulse-shell')).toHaveClass('pb-safe-or-14');
|
||||
expect(container.querySelector('.pulse-shell')).not.toHaveClass('pb-safe-or-16');
|
||||
expect(container.querySelector('.header')).toHaveClass('mb-1', 'sm:mb-3');
|
||||
expect(container.querySelector('main')).toHaveClass('mb-1', 'sm:mb-2');
|
||||
const main = container.querySelector('main');
|
||||
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');
|
||||
|
||||
const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' });
|
||||
|
||||
@@ -223,6 +223,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<Toggle
|
||||
checked={layoutStore.isFullWidth()}
|
||||
class="shrink-0"
|
||||
ariaLabel={t('settings.general.fullWidth.title')}
|
||||
onChange={() => layoutStore.toggle()}
|
||||
/>
|
||||
</div>
|
||||
@@ -276,6 +277,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<Toggle
|
||||
checked={props.telemetryEnabled()}
|
||||
class="shrink-0"
|
||||
ariaLabel={t('settings.general.telemetry.title')}
|
||||
disabled={props.telemetryEnabledLocked() || props.savingTelemetry()}
|
||||
onChange={() => props.handleTelemetryEnabledChange(!props.telemetryEnabled())}
|
||||
/>
|
||||
|
||||
+8
@@ -86,6 +86,14 @@ describe('GeneralSettingsPanel localization', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Usage data and privacy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Outbound usage telemetry')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Full-width mode' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Outbound usage telemetry' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Monitoring cadence')).toBeInTheDocument();
|
||||
|
||||
@@ -9,19 +9,29 @@ import {
|
||||
} from './dialogModel';
|
||||
import { useDialogState } from './useDialogState';
|
||||
|
||||
interface DialogProps {
|
||||
interface DialogBaseProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: JSX.Element;
|
||||
panelClass?: string;
|
||||
layout?: DialogLayout;
|
||||
closeOnBackdrop?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
returnFocus?: () => HTMLElement | null | undefined;
|
||||
}
|
||||
|
||||
type DialogProps = DialogBaseProps &
|
||||
(
|
||||
| {
|
||||
ariaLabel: string;
|
||||
ariaLabelledBy?: never;
|
||||
}
|
||||
| {
|
||||
ariaLabel?: never;
|
||||
ariaLabelledBy: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const Dialog: Component<DialogProps> = (props) => {
|
||||
const state = useDialogState(props);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import type { ComponentProps, JSX } from 'solid-js';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
|
||||
import dialogSource from '@/components/shared/Dialog.tsx?raw';
|
||||
@@ -53,10 +54,30 @@ describe('Dialog', () => {
|
||||
expect(dialogModelSource).toContain('FOCUSABLE_SELECTOR');
|
||||
});
|
||||
|
||||
it('requires exactly one accessible-name strategy at the component boundary', () => {
|
||||
type UnnamedDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: JSX.Element;
|
||||
};
|
||||
type DialogComponentProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
expectTypeOf<UnnamedDialogProps>().not.toMatchTypeOf<DialogComponentProps>();
|
||||
expectTypeOf<
|
||||
UnnamedDialogProps & { ariaLabel: string }
|
||||
>().toMatchTypeOf<DialogComponentProps>();
|
||||
expectTypeOf<
|
||||
UnnamedDialogProps & { ariaLabelledBy: string }
|
||||
>().toMatchTypeOf<DialogComponentProps>();
|
||||
expectTypeOf<
|
||||
UnnamedDialogProps & { ariaLabel: string; ariaLabelledBy: string }
|
||||
>().not.toMatchTypeOf<DialogComponentProps>();
|
||||
});
|
||||
|
||||
it('renders as a modal dialog and closes on backdrop click', () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => (
|
||||
<Dialog isOpen={true} onClose={onClose}>
|
||||
<Dialog isOpen={true} onClose={onClose} ariaLabel="Test dialog">
|
||||
<div class="p-4">
|
||||
<button type="button">Action</button>
|
||||
</div>
|
||||
@@ -76,7 +97,7 @@ describe('Dialog', () => {
|
||||
it('closes on Escape and locks body scroll while open', () => {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(() => (
|
||||
<Dialog isOpen={true} onClose={onClose}>
|
||||
<Dialog isOpen={true} onClose={onClose} ariaLabel="Test dialog">
|
||||
<div class="p-4">Body</div>
|
||||
</Dialog>
|
||||
));
|
||||
@@ -96,7 +117,7 @@ describe('Dialog', () => {
|
||||
expect(dialogStackHasBlockingDialog()).toBe(false);
|
||||
|
||||
const { unmount } = render(() => (
|
||||
<Dialog isOpen={true} onClose={() => undefined}>
|
||||
<Dialog isOpen={true} onClose={() => undefined} ariaLabel="Test dialog">
|
||||
<div class="p-4">Body</div>
|
||||
</Dialog>
|
||||
));
|
||||
@@ -112,7 +133,7 @@ describe('Dialog', () => {
|
||||
document.body.appendChild(background);
|
||||
|
||||
const { unmount } = render(() => (
|
||||
<Dialog isOpen={true} onClose={() => undefined}>
|
||||
<Dialog isOpen={true} onClose={() => undefined} ariaLabel="Test dialog">
|
||||
<button type="button">Dialog action</button>
|
||||
</Dialog>
|
||||
));
|
||||
@@ -133,7 +154,7 @@ describe('Dialog', () => {
|
||||
document.body.appendChild(background);
|
||||
|
||||
const { unmount } = render(() => (
|
||||
<Dialog isOpen={true} onClose={() => undefined}>
|
||||
<Dialog isOpen={true} onClose={() => undefined} ariaLabel="Test dialog">
|
||||
<button type="button">Dialog action</button>
|
||||
</Dialog>
|
||||
));
|
||||
@@ -178,7 +199,7 @@ describe('Dialog', () => {
|
||||
|
||||
it('makes body-level surfaces added while a dialog is open inert', async () => {
|
||||
render(() => (
|
||||
<Dialog isOpen={true} onClose={() => undefined}>
|
||||
<Dialog isOpen={true} onClose={() => undefined} ariaLabel="Test dialog">
|
||||
<button type="button">Dialog action</button>
|
||||
</Dialog>
|
||||
));
|
||||
@@ -194,7 +215,7 @@ describe('Dialog', () => {
|
||||
it('keeps keyboard focus trapped in the dialog', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => (
|
||||
<Dialog isOpen={true} onClose={onClose}>
|
||||
<Dialog isOpen={true} onClose={onClose} ariaLabel="Test dialog">
|
||||
<div class="p-4">
|
||||
<button type="button">First</button>
|
||||
<button type="button">Last</button>
|
||||
@@ -294,7 +315,7 @@ describe('Dialog', () => {
|
||||
|
||||
it('honors an explicitly requested initial focus target', async () => {
|
||||
render(() => (
|
||||
<Dialog isOpen={true} onClose={() => undefined}>
|
||||
<Dialog isOpen={true} onClose={() => undefined} ariaLabel="Test dialog">
|
||||
<div class="p-4">
|
||||
<button type="button">Close</button>
|
||||
<textarea aria-label="Outcome" autofocus />
|
||||
@@ -313,7 +334,7 @@ describe('Dialog', () => {
|
||||
<button type="button" onClick={() => setIsOpen(true)}>
|
||||
Open investigation
|
||||
</button>
|
||||
<Dialog isOpen={isOpen()} onClose={() => setIsOpen(false)}>
|
||||
<Dialog isOpen={isOpen()} onClose={() => setIsOpen(false)} ariaLabel="Investigation">
|
||||
<button type="button" onClick={() => setIsOpen(false)}>
|
||||
Close investigation
|
||||
</button>
|
||||
@@ -347,6 +368,7 @@ describe('Dialog', () => {
|
||||
</Show>
|
||||
<Dialog
|
||||
isOpen={isOpen()}
|
||||
ariaLabel="Remove item"
|
||||
onClose={() => {
|
||||
setShowTrigger(false);
|
||||
setIsOpen(false);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) {
|
||||
binaryPath := buildLifecycleAgent(t)
|
||||
root := t.TempDir()
|
||||
validPath := filepath.Join(root, "valid-agent-id")
|
||||
oversizedPath := filepath.Join(root, "oversized-agent-id")
|
||||
symlinkPath := filepath.Join(root, "symlink-agent-id")
|
||||
fifoPath := filepath.Join(root, "fifo-agent-id")
|
||||
if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(validPath, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(fifoPath, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
harness := func(path string) ([]byte, error) {
|
||||
script := `
|
||||
set -euo pipefail
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `"
|
||||
INSTALL_DIR="` + root + `"
|
||||
BINARY_NAME="pulse-agent"
|
||||
LEAST_PRIVILEGE_USER="pulse-agent-test-missing"
|
||||
` + extractLifecycleTrustShellFunctions(t) + `
|
||||
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
|
||||
` + extractInstallShellFunction(t, "read_agent_id_file_safely") + `
|
||||
read_agent_id_file_safely "` + path + `"
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput()
|
||||
}
|
||||
if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" {
|
||||
t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out)
|
||||
}
|
||||
for _, path := range []string{symlinkPath, fifoPath, oversizedPath} {
|
||||
started := time.Now()
|
||||
if out, err := harness(path); err == nil {
|
||||
t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 2*time.Second {
|
||||
t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package installtests
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -21,59 +20,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix descriptor-bound identity recovery")
|
||||
}
|
||||
binaryPath := buildLifecycleAgent(t)
|
||||
root := t.TempDir()
|
||||
validPath := filepath.Join(root, "valid-agent-id")
|
||||
oversizedPath := filepath.Join(root, "oversized-agent-id")
|
||||
symlinkPath := filepath.Join(root, "symlink-agent-id")
|
||||
fifoPath := filepath.Join(root, "fifo-agent-id")
|
||||
if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(validPath, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(fifoPath, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
harness := func(path string) ([]byte, error) {
|
||||
script := `
|
||||
set -euo pipefail
|
||||
COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `"
|
||||
INSTALL_DIR="` + root + `"
|
||||
BINARY_NAME="pulse-agent"
|
||||
LEAST_PRIVILEGE_USER="pulse-agent-test-missing"
|
||||
` + extractLifecycleTrustShellFunctions(t) + `
|
||||
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
|
||||
` + extractInstallShellFunction(t, "read_agent_id_file_safely") + `
|
||||
read_agent_id_file_safely "` + path + `"
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput()
|
||||
}
|
||||
if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" {
|
||||
t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out)
|
||||
}
|
||||
for _, path := range []string{symlinkPath, fifoPath, oversizedPath} {
|
||||
started := time.Now()
|
||||
if out, err := harness(path); err == nil {
|
||||
t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 2*time.Second {
|
||||
t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type agentLifecycleControlPlane struct {
|
||||
mu sync.Mutex
|
||||
online bool
|
||||
|
||||
@@ -933,6 +933,10 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
||||
convergenceWorkflow := string(convergenceContent)
|
||||
required := []string{
|
||||
`historical_asset_backfill_only:`,
|
||||
`expected_source_sha:`,
|
||||
`EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}`,
|
||||
`"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"`,
|
||||
`"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"`,
|
||||
`description: 'Repair an already-published release packet in place without rebuilding binaries'`,
|
||||
`SYFT_VERSION="1.42.4"`,
|
||||
`SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`,
|
||||
|
||||
@@ -1689,6 +1689,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn("build_rollback_section", renderer)
|
||||
self.assertIn("promotion metadata out of customer notes", renderer)
|
||||
self.assertIn("historical_asset_backfill_only:", content)
|
||||
self.assertIn("expected_source_sha:", content)
|
||||
self.assertIn('EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}', content)
|
||||
self.assertIn('"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"', content)
|
||||
self.assertIn('"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"', content)
|
||||
self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content)
|
||||
self.assertIn("draft: true", content)
|
||||
self.assertIn("activate_release:", content)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pkg/agents/docker/report_limits.go",
|
||||
"scripts/install.sh",
|
||||
"scripts/release_ldflags.sh",
|
||||
"scripts/installtests/agent_id_recovery_unix_test.go",
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go",
|
||||
"scripts/installtests/backfill_release_assets_test.go",
|
||||
"scripts/installtests/build_release_assets_test.go",
|
||||
|
||||
@@ -120,6 +120,7 @@ python3 scripts/check-workflow-dispatch-inputs.py \
|
||||
--workflow-path .github/workflows/create-release.yml \
|
||||
--branch "$CURRENT_BRANCH" \
|
||||
--require version \
|
||||
--require expected_source_sha \
|
||||
--require release_notes \
|
||||
--require release_screenshot_plan \
|
||||
--require promoted_from_tag \
|
||||
@@ -372,6 +373,7 @@ echo "Triggering release workflow..."
|
||||
if [ -n "$NOTES_FILE" ]; then
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg expected_source_sha "$LOCAL" \
|
||||
--rawfile release_notes "$NOTES_FILE" \
|
||||
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
|
||||
--arg rollback_version "$ROLLBACK_VERSION" \
|
||||
@@ -387,6 +389,7 @@ if [ -n "$NOTES_FILE" ]; then
|
||||
--arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \
|
||||
'{
|
||||
version: $version,
|
||||
expected_source_sha: $expected_source_sha,
|
||||
release_notes: $release_notes,
|
||||
release_screenshot_plan: $release_screenshot_plan,
|
||||
rollback_version: $rollback_version,
|
||||
|
||||
@@ -203,6 +203,7 @@ else
|
||||
--workflow-path .github/workflows/create-release.yml \
|
||||
--branch "$CURRENT_BRANCH" \
|
||||
--require version \
|
||||
--require expected_source_sha \
|
||||
--require release_notes \
|
||||
--require release_screenshot_plan \
|
||||
--require promoted_from_tag \
|
||||
@@ -219,6 +220,7 @@ else
|
||||
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg expected_source_sha "$LOCAL_SHA" \
|
||||
--rawfile release_notes "$NOTES_FILE" \
|
||||
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
|
||||
--arg promoted_from_tag "" \
|
||||
@@ -234,6 +236,7 @@ else
|
||||
--arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \
|
||||
'{
|
||||
version: $version,
|
||||
expected_source_sha: $expected_source_sha,
|
||||
release_notes: $release_notes,
|
||||
release_screenshot_plan: $release_screenshot_plan,
|
||||
promoted_from_tag: $promoted_from_tag,
|
||||
|
||||
@@ -137,8 +137,19 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeFalsy();
|
||||
|
||||
const skipLink = page.getByRole("link", { name: "Skip to main content" });
|
||||
await page.evaluate(() => {
|
||||
document.body.tabIndex = -1;
|
||||
document.body.focus();
|
||||
document.body.removeAttribute("tabindex");
|
||||
});
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus")).toBeVisible();
|
||||
await expect(skipLink).toBeFocused();
|
||||
await expect(skipLink).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#main")).toBeFocused();
|
||||
|
||||
await testInfo.attach("actions-phone-width", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
|
||||
Reference in New Issue
Block a user