Harden RC qualification coverage

Contract-Neutral: RC qualification and contract-preserving reliability fixes; no canonical product contract changes
This commit is contained in:
rcourtman 2026-07-20 01:13:26 +01:00
parent dbd4b14b73
commit e9dddf86b8
16 changed files with 356 additions and 347 deletions

View file

@ -59,6 +59,10 @@ jobs:
npm ci
npx playwright install --with-deps chromium webkit
- name: Install Pulse Account frontend dependencies
working-directory: internal/cloudcp/portal/frontend
run: npm ci
- name: Build Docker images for test environment
# GO_BUILD_TAGS="" drops the release build tag so the suite can enable
# mock fixtures; release-tag gating has its own -tags release Go tests.
@ -114,16 +118,97 @@ jobs:
path: tests/integration/test-results/
retention-days: 7
agent-registration:
name: Agent registration lifecycle
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/integration/package-lock.json
- name: Install Playwright dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Install Pulse Account frontend dependencies
working-directory: internal/cloudcp/portal/frontend
run: npm ci
- name: Build test images
run: |
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
docker build -t pulse-mock-github:test ./tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Start non-mock test container
working-directory: tests/integration
env:
PULSE_BASE_URL: http://localhost:7655
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true"
PULSE_MOCK_MODE: "false"
PULSE_MULTI_TENANT_ENABLED: "true"
run: node scripts/pretest.mjs
- name: Run agent registration lifecycle
working-directory: tests/integration
env:
PULSE_BASE_URL: http://localhost:7655
PULSE_E2E_AGENT_JOURNEY: "true"
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
PULSE_E2E_SKIP_DOCKER: "true"
PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true"
PULSE_MOCK_MODE: "false"
PULSE_MULTI_TENANT_ENABLED: "true"
run: npx playwright test tests/journeys/04-agent-install-registration.spec.ts --project=chromium
- name: Collect container logs
if: always()
working-directory: tests/integration
run: docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-agent-registration
path: tests/integration/playwright-report/
retention-days: 30
- name: Upload test videos and screenshots
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-failures-agent-registration
path: tests/integration/test-results/
retention-days: 7
e2e-verdict:
name: E2E verdict
runs-on: ubuntu-24.04
needs: e2e
needs:
- e2e
- agent-registration
if: always()
steps:
- name: Check shard results
- name: Check E2E results
run: |
if [ "${{ needs.e2e.result }}" != "success" ]; then
echo "E2E shards did not all pass (result: ${{ needs.e2e.result }})"
exit 1
fi
echo "All E2E shards passed"
if [ "${{ needs.agent-registration.result }}" != "success" ]; then
echo "Agent registration lifecycle did not pass (result: ${{ needs.agent-registration.result }})"
exit 1
fi
echo "All E2E shards and the agent registration lifecycle passed"

View file

@ -1,6 +1,6 @@
import { Show, lazy, createSignal, createEffect, createMemo, onCleanup, onMount } from 'solid-js';
import type { JSX } from 'solid-js';
import { Router, Route, useNavigate, useLocation } from '@solidjs/router';
import { Navigate, Router, Route, useNavigate, useLocation } from '@solidjs/router';
import { ToastContainer } from './components/Toast/Toast';
import { ErrorBoundary, RouteErrorBoundary } from './components/ErrorBoundary';
import { SecurityWarning } from './components/SecurityWarning';
@ -108,6 +108,8 @@ const SetupCompletionPreviewPage = lazy(() =>
);
const ROOT_PATROL_PATH = PATROL_PATH;
const RetiredDockerWorkloadsRoute = () => <Navigate href={buildDockerPath()} />;
const PRIMARY_INFRASTRUCTURE_ROUTE_BY_ID: Record<PrimaryPlatformNavId, string> = {
proxmox: buildProxmoxPath(),
docker: buildDockerPath(),
@ -565,6 +567,7 @@ function App() {
<Route path={PROXMOX_PATH} component={ProxmoxPage} />
<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />
<Route path={DOCKER_PATH} component={DockerPage} />
<Route path={`${DOCKER_PATH}/workloads`} component={RetiredDockerWorkloadsRoute} />
<Route path={`${DOCKER_PATH}/*`} component={DockerPage} />
<Route path={KUBERNETES_PATH} component={KubernetesPage} />
<Route path={`${KUBERNETES_PATH}/*`} component={KubernetesPage} />

View file

@ -25,6 +25,9 @@ export function UpdateBanner() {
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
onCleanup(() => {
// Invalidate any plan request still settling after this banner unmounts.
// Navigation-aborted requests are stale work, not user-visible failures.
latestPlanRequestID += 1;
if (copiedTimer) clearTimeout(copiedTimer);
});
@ -46,9 +49,8 @@ export function UpdateBanner() {
}
})
.catch((error) => {
if (requestID === latestPlanRequestID) {
setUpdatePlan(null);
}
if (requestID !== latestPlanRequestID) return;
setUpdatePlan(null);
logger.error('Failed to fetch update plan', error);
});
});

View file

@ -1,5 +1,5 @@
import { useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { Show, createEffect, createMemo, createSignal } from 'solid-js';
import { useLocation, useSearchParams } from '@solidjs/router';
import { Show, createMemo, createSignal } from 'solid-js';
import BoxIcon from 'lucide-solid/icons/box';
import { ButtonLink } from '@/components/shared/Button';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
@ -48,7 +48,7 @@ import {
buildInfrastructureAgentUpdatesPath,
buildInfrastructureOnboardingPath,
} from '@/components/Settings/infrastructureWorkspaceModel';
import { buildDockerPath, DOCKER_PATH, DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
import { asTrimmedString } from '@/utils/stringUtils';
import type { Resource } from '@/types/resource';
@ -60,7 +60,6 @@ const dockerIcon = () => <DockerIcon class="h-6 w-6 text-slate-400" />;
export function DockerPageSurface() {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { resources, loading, error, refetch } = useUnifiedResources({
query: DOCKER_RESOURCE_QUERY,
@ -77,13 +76,6 @@ export function DockerPageSurface() {
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
createEffect(() => {
const pathname = location.pathname.replace(/\/+$/, '');
if (pathname === `${DOCKER_PATH}/workloads`) {
navigate(buildDockerPath(), { replace: true });
}
});
const hostFilter = createMemo(() => {
const rawHost = searchParams[DOCKER_QUERY_PARAMS.host];
return typeof rawHost === 'string' ? rawHost.trim() : '';

View file

@ -7,7 +7,6 @@ import { DockerPageSurface } from '../DockerPageSurface';
const mocks = vi.hoisted(() => ({
pathname: '/docker/overview',
searchParams: {} as Record<string, string>,
navigate: vi.fn(),
useUnifiedResources: vi.fn(),
DockerHostsTable: vi.fn(
(props: { resources: Resource[]; showToolbar?: boolean; emptyTitle: string }) => (
@ -89,7 +88,6 @@ vi.mock('@solidjs/router', () => ({
return mocks.pathname;
},
}),
useNavigate: () => mocks.navigate,
useSearchParams: () => [mocks.searchParams, vi.fn()],
}));
@ -425,14 +423,12 @@ describe('DockerPageSurface', () => {
);
});
it('replaces the retired workloads route with the canonical Overview route', () => {
it('renders the Overview fallback when the router has not canonicalized a retired route', () => {
mocks.pathname = '/docker/workloads';
render(() => <DockerPageSurface />);
expect(mocks.navigate).toHaveBeenCalledWith('/docker/overview', {
replace: true,
});
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview');
});
it('shows the Swarm tab only when Docker hosts report Swarm evidence', () => {

View file

@ -848,6 +848,9 @@ func TestStoreRetentionReclaimsFreePages(t *testing.T) {
t.Fatalf("NewStore returned error: %v", err)
}
defer store.Close()
if err := store.WaitForMaintenance(10 * time.Second); err != nil {
t.Fatalf("wait for startup maintenance: %v", err)
}
// Build a freelist backlog: insert many rows (kept unique by timestamp) then
// delete them directly, so the rows are already gone before runRetention.

View file

@ -76,8 +76,9 @@ services:
- PULSE_DATA_DIR=/data
# Enable debug logging
- PULSE_LOG_LEVEL=debug
# Mock mode for faster testing
- PULSE_MOCK_MODE=true
# Mock mode is the default shared-estate contract. Dedicated lifecycle
# jobs can disable it without maintaining a second compose definition.
- PULSE_MOCK_MODE=${PULSE_MOCK_MODE:-true}
# Dev-only endpoints the suite depends on (first-run reset in the
# first-session specs) are gated on development mode.
- PULSE_DEV=true

View file

@ -392,7 +392,9 @@ test.describe.serial("First-session experience", () => {
);
await ensureAuthenticated(page);
await page.goto("/settings/system-pro", { waitUntil: "domcontentloaded" });
await page.goto("/settings/pulse-intelligence/billing/plan", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings/, { timeout: 10_000 });
// The self-hosted plan panel should always render with plan/recovery

View file

@ -70,11 +70,12 @@ test.describe('VMware AI chat mentions', () => {
// ESXi hosts surface as shared agent mention targets.
// First websocket state frame can lag on a freshly booted backend, and
// the candidate list refreshes as state arrives.
await textarea.click();
await textarea.pressSequentially('@esxi');
await textarea.focus();
await textarea.fill('@esxi');
await expect(mentionListbox).toBeVisible({ timeout: 30_000 });
const hostOption = mentionListbox
.getByRole('option', { name: /esxi-01\.lab\.local: agent/ })
.getByRole('option')
.filter({ hasText: /esxi-01\.lab\.local/ })
.first();
await expect(hostOption).toBeVisible({ timeout: 30_000 });
await hostOption.click();
@ -83,10 +84,11 @@ test.describe('VMware AI chat mentions', () => {
// API-backed vSphere VMs use the same shared mention contract, carrying
// their runtime host as the mention node.
await textarea.fill('');
await textarea.pressSequentially('@warehouse');
await textarea.fill('@warehouse');
await expect(mentionListbox).toBeVisible();
const vmOption = mentionListbox
.getByRole('option', { name: /warehouse-api-01: vm on esxi-01\.lab\.local/ })
.getByRole('option')
.filter({ hasText: /warehouse-api-01/ })
.first();
await expect(vmOption).toBeVisible({ timeout: 30_000 });
await vmOption.click();

View file

@ -43,7 +43,11 @@ const test = base.extend<{}, WorkerFixtures>({
test.describe('VMware resource detail drawer', () => {
test.setTimeout(180_000);
test('surfaces VMware read-only context through the shared guest drawer', async ({ page }) => {
test('surfaces VMware read-only context through the shared guest drawer', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop table drawer contract');
let unexpectedVmwareApiCall: string | null = null;
await page.route('**/api/vmware/**', async (route) => {

View file

@ -70,7 +70,8 @@ test.describe('VMware AI chat read recovery', () => {
const mentionListbox = page.getByRole('listbox', { name: 'Assistant resources' });
await expect(mentionListbox).toBeVisible({ timeout: 30_000 });
await mentionListbox
.getByRole('option', { name: /warehouse-api-01: vm on esxi-01\.lab\.local/ })
.getByRole('option')
.filter({ hasText: /warehouse-api-01/ })
.first()
.click();
await expect(textarea).toHaveValue('@warehouse-api-01 ');

View file

@ -64,7 +64,11 @@ test.describe.serial('Storage physical disk drawer history', () => {
}
});
test('renders canonical live I/O history for Proxmox physical disks', async ({ page }) => {
test('renders canonical live I/O history for Proxmox physical disks', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop physical-disk table contract');
await ensureMockModeEnabled(page);
const diskHistoryResponses = new Map<string, { resourceId: string; points: number }>();
@ -131,7 +135,11 @@ test.describe.serial('Storage physical disk drawer history', () => {
}
});
test('keeps visible physical-disk drawers hydrated with live I/O history', async ({ page }) => {
test('keeps visible physical-disk drawers hydrated with live I/O history', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop physical-disk table contract');
await ensureMockModeEnabled(page);
await page.goto('/proxmox/storage', { waitUntil: 'domcontentloaded' });

View file

@ -135,6 +135,36 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
},
];
// Every populated inventory tab — embedded canonical Workloads/Storage and
// UnifiedResourceTable-backed infrastructure views — owns an operator search
// input. Overview stacks are summary surfaces and intentionally suppress their
// table toolbars. Keep each route as an independent browser test so one slow
// navigation cannot consume the time budget or state of the remaining routes.
const OPERATOR_SEARCH_CASES: ReadonlyArray<{ path: string; testId: string }> = [
{ path: '/docker/images', testId: 'docker-page' },
{ path: '/docker/storage', testId: 'docker-page' },
{ path: '/docker/networks', testId: 'docker-page' },
// The default mock inventory intentionally has no Swarm resources. Empty
// inventory tabs omit search because there is nothing actionable to filter;
// populated Swarm table toolbars have component coverage.
{ path: '/kubernetes/nodes', testId: 'kubernetes-page' },
{ path: '/kubernetes/workloads', testId: 'kubernetes-page' },
{ path: '/kubernetes/services', testId: 'kubernetes-page' },
{ path: '/kubernetes/storage', testId: 'kubernetes-page' },
{ path: '/kubernetes/configuration', testId: 'kubernetes-page' },
{ path: '/kubernetes/events', testId: 'kubernetes-page' },
{ path: '/truenas/storage', testId: 'truenas-page' },
{ path: '/truenas/services', testId: 'truenas-page' },
{ path: '/truenas/apps', testId: 'truenas-page' },
{ path: '/truenas/vms', testId: 'truenas-page' },
{ path: '/truenas/shares', testId: 'truenas-page' },
{ path: '/truenas/protection', testId: 'truenas-page' },
{ path: '/vmware/storage', testId: 'vmware-page' },
{ path: '/vmware/networks', testId: 'vmware-page' },
{ path: '/vmware/health', testId: 'vmware-page' },
{ path: '/vmware/activity', testId: 'vmware-page' },
];
const stubEmptyResources = async (page: Page) => {
await page.route('**/api/resources**', async (route) => {
const requestUrl = new URL(route.request().url());
@ -241,46 +271,14 @@ test.describe('Platform pages shell', () => {
}
});
test('every platform inventory sub-tab exposes canonical operator search', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop chrome audit');
for (const operatorSearchCase of OPERATOR_SEARCH_CASES) {
test(`operator search is available on ${operatorSearchCase.path}`, async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop chrome audit');
// Every populated inventory tab — embedded canonical Workloads/Storage AND
// UnifiedResourceTable-backed infra views — must render an operator
// search input under platform-page chrome. Overview stacks are summary
// surfaces and intentionally keep their table toolbars suppressed. The shared
// PlatformResourceTable wrapper provides the toolbar for the
// UnifiedResourceTable-backed tabs; the embedded surfaces use their
// own canonical FilterBar via `showFilterToolbar`.
const cases: ReadonlyArray<{ path: string; testId: string }> = [
{ path: '/docker/images', testId: 'docker-page' },
{ path: '/docker/storage', testId: 'docker-page' },
{ path: '/docker/networks', testId: 'docker-page' },
// The default mock inventory intentionally has no Swarm resources.
// Empty inventory tabs omit search because there is nothing actionable
// to filter; populated Swarm table toolbars have component coverage.
{ path: '/kubernetes/nodes', testId: 'kubernetes-page' },
{ path: '/kubernetes/workloads', testId: 'kubernetes-page' },
{ path: '/kubernetes/services', testId: 'kubernetes-page' },
{ path: '/kubernetes/storage', testId: 'kubernetes-page' },
{ path: '/kubernetes/configuration', testId: 'kubernetes-page' },
{ path: '/kubernetes/events', testId: 'kubernetes-page' },
{ path: '/truenas/storage', testId: 'truenas-page' },
{ path: '/truenas/services', testId: 'truenas-page' },
{ path: '/truenas/apps', testId: 'truenas-page' },
{ path: '/truenas/vms', testId: 'truenas-page' },
{ path: '/truenas/shares', testId: 'truenas-page' },
{ path: '/truenas/protection', testId: 'truenas-page' },
{ path: '/vmware/storage', testId: 'vmware-page' },
{ path: '/vmware/networks', testId: 'vmware-page' },
{ path: '/vmware/health', testId: 'vmware-page' },
{ path: '/vmware/activity', testId: 'vmware-page' },
];
for (const c of cases) {
await page.goto(c.path, { waitUntil: 'domcontentloaded' });
const pageRoot = page.getByTestId(c.testId);
await page.goto(operatorSearchCase.path, { waitUntil: 'domcontentloaded' });
const pageRoot = page.getByTestId(operatorSearchCase.testId);
await expect(pageRoot).toBeVisible({ timeout: 30_000 });
await expect(
@ -290,6 +288,6 @@ test.describe('Platform pages shell', () => {
)
.first(),
).toBeVisible({ timeout: 30_000 });
}
});
});
}
});

View file

@ -4,7 +4,9 @@ import { ensureAuthenticated } from './helpers';
test.describe('Docker workload route ownership', () => {
test('retires legacy Docker workload deep links into the runtime overview', async ({
page,
}) => {
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop route ownership contract');
await ensureAuthenticated(page);
await page.goto(

View file

@ -1059,7 +1059,10 @@ async function authenticateWithPrimaryAPIToken(page: Page): Promise<boolean> {
return true;
}
forgetRejectedPrimaryAPIToken(candidate);
// The probe above already proved this token is valid. A transient app
// navigation or websocket delay must not permanently evict it and force
// later fixtures through the rate-limited password login path. Only a
// definitive 401/403 from the token probe rejects a candidate.
await page
.evaluate(() => {
window.sessionStorage.removeItem("pulse_auth");
@ -1539,12 +1542,32 @@ export async function apiRequest(
});
}
const cookies = await page.context().cookies(baseURL);
const hasSessionCookie = cookies.some(
(cookie) =>
cookie.name === "pulse_session" ||
cookie.name === "__Host-pulse_session",
);
if (!hasSessionCookie) {
const primaryAPIToken = configuredPrimaryAPIToken();
if (primaryAPIToken) {
// ensureAuthenticated may intentionally use the browser-only primary
// token path. Carry the same validated credential into API requests
// without introducing an ambient cookie that would activate CSRF.
headers["X-API-Token"] = primaryAPIToken;
const context = await getTokenAuthRequestContext();
return context.fetch(`${baseURL}${endpoint}`, {
...options,
headers,
});
}
}
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const hasCSRFHeader = Object.keys(headers).some(
(name) => name.toLowerCase() === "x-csrf-token",
);
if (!hasCSRFHeader) {
const cookies = await page.context().cookies(baseURL);
const csrfCookie = cookies.find(
(cookie) => cookie.name === "pulse_csrf",
)?.value;

View file

@ -1,22 +1,21 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
import {
ensureAuthenticated,
apiRequest,
setMockMode,
getMockMode,
} from '../helpers';
setMockMode,
} from "../helpers";
/**
* Journey: Agent Install → Registration → Host Visible in UI/API
*
* Covers the unified agent lifecycle:
* 1. Disable mock mode to start with a clean infrastructure state
* 2. Simulate agent registration via POST /api/agents/agent/report
* 3. Verify the agent resource appears in GET /api/state resources[]
* 4. Verify the host is visible on the infrastructure page
* 5. Verify resource metadata and metrics in the unified state response
* 6. Send a second report and verify last-seen updates
* 7. Delete the agent and verify removal
* 1. Simulate agent registration via POST /api/agents/agent/report
* 2. Verify the agent resource appears in GET /api/state resources[]
* 3. Verify the host is visible on the Machines page
* 4. Verify resource metadata and metrics in the unified state response
* 5. Send a second report and verify last-seen updates
* 6. Delete the agent and verify removal
*
* This satisfies L12 score-4 criteria: "Agent install → registration →
* host visible in UI/API."
@ -31,41 +30,44 @@ import {
const TEST_HOST_ID = `e2e-host-${Date.now()}`;
const TEST_HOSTNAME = `e2e-agent-test-${Date.now()}`;
const TEST_AGENT_ID = `e2e-agent-${Date.now()}`;
/** Whether mock mode was enabled before the journey (for cleanup). */
let mockModeWasEnabled: boolean | null = null;
const TEST_DISPLAY_NAME = `E2E Test Host (${TEST_HOSTNAME})`;
/** Track whether the agent resource was successfully registered (for cleanup). */
let hostRegistered = false;
/** The agentId returned by the server (may differ from TEST_HOST_ID). */
let serverAgentId = '';
let serverAgentId = "";
type StateResource = Record<string, unknown>;
const readString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim().length > 0 ? value : undefined;
typeof value === "string" && value.trim().length > 0 ? value : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value)
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const findRegisteredAgentResource = (
state: Record<string, unknown>,
): StateResource | undefined => {
const resources = state.resources as Array<Record<string, unknown>> | undefined;
const resources = state.resources as
Array<Record<string, unknown>> | undefined;
return resources?.find((resource) => {
if (resource.type !== 'agent') {
if (resource.type !== "agent") {
return false;
}
if (resource.id === serverAgentId) {
const agent = asRecord(resource.agent);
if (agent?.agentId === serverAgentId || agent?.hostname === TEST_HOSTNAME) {
return true;
}
if (resource.name === TEST_HOSTNAME || resource.displayName === TEST_HOSTNAME) {
if (
resource.name === TEST_DISPLAY_NAME ||
resource.displayName === TEST_DISPLAY_NAME
) {
return true;
}
return false;
@ -85,20 +87,20 @@ function buildHostReport(overrides?: {
return {
agent: {
id: TEST_AGENT_ID,
version: '1.0.0-e2e',
type: 'unified',
version: "1.0.0-e2e",
type: "unified",
},
host: {
id: TEST_HOST_ID,
hostname: TEST_HOSTNAME,
displayName: `E2E Test Host (${TEST_HOSTNAME})`,
displayName: TEST_DISPLAY_NAME,
machineId: `e2e-machine-${Date.now()}`,
platform: 'linux',
osName: 'Ubuntu',
osVersion: '24.04 LTS',
kernelVersion: '6.8.0-e2e',
architecture: 'x86_64',
cpuModel: 'E2E Virtual CPU',
platform: "linux",
osName: "Ubuntu",
osVersion: "24.04 LTS",
kernelVersion: "6.8.0-e2e",
architecture: "x86_64",
cpuModel: "E2E Virtual CPU",
cpuCount: 4,
uptimeSeconds: 86400,
loadAverage: [1.5, 1.2, 0.9],
@ -116,9 +118,9 @@ function buildHostReport(overrides?: {
},
disks: [
{
device: '/dev/sda1',
mountpoint: '/',
type: 'ext4',
device: "/dev/sda1",
mountpoint: "/",
type: "ext4",
totalBytes: 107374182400, // 100 GB
usedBytes: 32212254720, // ~30 GB
freeBytes: 75161927680,
@ -127,9 +129,9 @@ function buildHostReport(overrides?: {
],
network: [
{
name: 'eth0',
mac: '02:e2:e2:00:00:01',
addresses: ['192.168.100.99'],
name: "eth0",
mac: "02:e2:e2:00:00:01",
addresses: ["192.168.100.99"],
rxBytes: 1000000,
txBytes: 500000,
},
@ -142,40 +144,27 @@ function buildHostReport(overrides?: {
* Re-register the test host if it's missing from state.
* The backend file watcher may restart Pulse during the test suite
* (especially with parallel dev work), losing in-memory host state.
* Also ensures mock mode is off — the API toggle only sets an env var,
* not the mock.env file, so a backend restart may restore mock mode.
* Returns the unified agent resource from /api/state after ensuring it exists.
*/
async function ensureHostRegistered(
page: import('@playwright/test').Page,
page: import("@playwright/test").Page,
): Promise<Record<string, unknown>> {
// Ensure mock mode is still off (backend restart restores mock.env defaults).
// Retry once if the backend is mid-restart when this first runs.
for (let mockAttempt = 0; mockAttempt < 2; mockAttempt++) {
try {
const mockState = await getMockMode(page);
if (mockState.enabled) {
await setMockMode(page, false);
}
break;
} catch {
if (mockAttempt === 0) await page.waitForTimeout(2000);
}
}
const stateRes = await apiRequest(page, '/api/state');
let observedResources: Array<Record<string, unknown>> = [];
const stateRes = await apiRequest(page, "/api/state");
if (stateRes.ok()) {
const state = (await stateRes.json()) as Record<string, unknown>;
observedResources =
(state.resources as Array<Record<string, unknown>> | undefined) ?? [];
const found = findRegisteredAgentResource(state);
if (found) return found;
}
// Host not found — re-register.
const report = buildHostReport();
const regRes = await apiRequest(page, '/api/agents/agent/report', {
method: 'POST',
const regRes = await apiRequest(page, "/api/agents/agent/report", {
method: "POST",
data: report,
headers: { 'Content-Type': 'application/json' },
headers: { "Content-Type": "application/json" },
});
if (!regRes.ok()) {
throw new Error(`Host re-registration failed: ${regRes.status()}`);
@ -187,88 +176,67 @@ async function ensureHostRegistered(
// Poll state after re-registration to allow for short settling windows
// (e.g. backend just finished restarting and state is still initializing).
for (let attempt = 0; attempt < 5; attempt++) {
const stateRes2 = await apiRequest(page, '/api/state');
const stateRes2 = await apiRequest(page, "/api/state");
if (stateRes2.ok()) {
const state2 = (await stateRes2.json()) as Record<string, unknown>;
observedResources =
(state2.resources as Array<Record<string, unknown>> | undefined) ?? [];
const found = findRegisteredAgentResource(state2);
if (found) return found;
}
await page.waitForTimeout(500);
}
throw new Error('Host not found in state even after re-registration');
const resourceIdentities = observedResources.slice(0, 20).map((resource) => ({
id: resource.id,
name: resource.name,
platformType: resource.platformType,
type: resource.type,
}));
throw new Error(
`Host not found in state even after re-registration; observed resources: ${JSON.stringify(resourceIdentities)}`,
);
}
test.describe.serial(
'Journey: Agent Install → Registration → Host Visible',
() => {
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await ensureAuthenticated(page);
const state = await getMockMode(page);
mockModeWasEnabled = state.enabled;
// Disable mock mode so the test host is distinguishable in the UI.
if (state.enabled) {
await setMockMode(page, false);
}
} catch (err) {
console.warn('[journey setup] failed to configure mock mode:', err);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await ensureAuthenticated(page);
// Delete the test host if it was registered.
if (hostRegistered && serverAgentId) {
const delRes = await apiRequest(
page,
`/api/agents/agent/${serverAgentId}`,
{ method: 'DELETE' },
);
if (!delRes.ok()) {
console.warn(
`[journey cleanup] DELETE host ${serverAgentId} returned ${delRes.status()}`,
);
}
}
// Restore mock mode to its original state.
if (mockModeWasEnabled !== null) {
const current = await getMockMode(page);
if (current.enabled !== mockModeWasEnabled) {
await setMockMode(page, mockModeWasEnabled);
}
}
} catch (err) {
console.warn('[journey cleanup] failed to clean up:', err);
} finally {
await ctx.close();
}
});
test('simulate agent registration via unified agent report', async (
{ page },
testInfo,
) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.describe("Journey: Agent Install → Registration → Host Visible", () => {
test.skip(
process.env.PULSE_E2E_AGENT_JOURNEY !== "true",
"Runs in the dedicated non-mock agent lifecycle lane",
);
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await ensureAuthenticated(page);
if ((await getMockMode(page)).enabled) {
await setMockMode(page, false);
}
expect(
(await getMockMode(page)).enabled,
"Dedicated agent lifecycle lane must run without mock state",
).toBe(false);
} finally {
await context.close();
}
});
test("registers, projects, updates, renders, and deletes a unified agent", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop agent journey",
);
test.setTimeout(120_000);
await ensureAuthenticated(page);
try {
const report = buildHostReport();
const res = await apiRequest(page, '/api/agents/agent/report', {
method: 'POST',
const res = await apiRequest(page, "/api/agents/agent/report", {
method: "POST",
data: report,
headers: { 'Content-Type': 'application/json' },
headers: { "Content-Type": "application/json" },
});
expect(
@ -277,198 +245,117 @@ test.describe.serial(
).toBeTruthy();
const body = (await res.json()) as Record<string, unknown>;
expect(body.success, 'Report response should indicate success').toBe(
expect(body.success, "Report response should indicate success").toBe(
true,
);
expect(body.agentId, 'Response must include an agentId').toBeTruthy();
expect(body.agentId, "Response must include an agentId").toBeTruthy();
serverAgentId = body.agentId as string;
hostRegistered = true;
// Verify basic fields in the response.
expect(body.platform).toBe('linux');
expect(body.osName).toBe('Ubuntu');
expect(body.osVersion).toBe('24.04 LTS');
});
expect(body.platform).toBe("linux");
expect(body.osName).toBe("Ubuntu");
expect(body.osVersion).toBe("24.04 LTS");
test('agent resource appears in /api/state resources[]', async (
{ page },
testInfo,
) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.skip(!hostRegistered, 'Host was not registered');
test.setTimeout(30_000);
await ensureAuthenticated(page);
// Ensure the host is in state (re-registers if backend restarted).
const foundHost = await ensureHostRegistered(page);
const platformData = asRecord(foundHost.platformData);
const agent = asRecord(foundHost.agent);
// Verify the unified resource matches what we reported.
expect(foundHost.type).toBe('agent');
expect(foundHost.platformType).toBe('agent');
expect(foundHost.sourceType).toBe('agent');
expect(foundHost.name).toBe(TEST_HOSTNAME);
expect(readString(platformData?.platform)).toBe('linux');
expect(readString(platformData?.osName)).toBe('Ubuntu');
expect(readString(platformData?.osVersion)).toBe('24.04 LTS');
expect(readString(platformData?.architecture)).toBe('x86_64');
});
expect(foundHost.type).toBe("agent");
expect(foundHost.platformType).toBe("agent");
expect(foundHost.sourceType).toBe("agent");
expect(foundHost.name).toBe(TEST_DISPLAY_NAME);
expect(readString(agent?.agentId)).toBe(serverAgentId);
expect(readString(agent?.hostname)).toBe(TEST_HOSTNAME);
expect(readString(platformData?.platform)).toBe("linux");
expect(readString(platformData?.osName)).toBe("Ubuntu");
expect(readString(platformData?.osVersion)).toBe("24.04 LTS");
expect(readString(platformData?.architecture)).toBe("x86_64");
test('agent resource includes CPU and memory metrics', async (
{ page },
testInfo,
) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.skip(!hostRegistered, 'Host was not registered');
await ensureAuthenticated(page);
const host = await ensureHostRegistered(page);
const memory = asRecord(host.memory);
// CPU usage should be close to what we reported (42.5%).
const cpu = asRecord(host.cpu);
const cpuUsage = readNumber(cpu?.current);
const memory = asRecord(foundHost.memory);
const cpuUsage = readNumber(asRecord(foundHost.cpu)?.current);
expect(cpuUsage).toBeGreaterThan(0);
expect(cpuUsage).toBeLessThanOrEqual(100);
// Memory should be present.
expect(memory, 'Host should have memory metrics').toBeTruthy();
expect(memory, "Host should have memory metrics").toBeTruthy();
expect(readNumber(memory!.total)).toBeGreaterThan(0);
expect(readNumber(memory!.used)).toBeGreaterThan(0);
});
test('updated report refreshes host last-seen', async (
{ page },
testInfo,
) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.skip(!hostRegistered, 'Host was not registered');
test.setTimeout(30_000);
await ensureAuthenticated(page);
const host1 = await ensureHostRegistered(page);
const lastSeen1 = readNumber(host1.lastSeen);
// Wait briefly and send a second report with updated metrics.
const lastSeen1 = readNumber(foundHost.lastSeen);
await page.waitForTimeout(1100);
const report2 = buildHostReport({ cpuUsage: 75.0, memUsage: 80.0 });
const res = await apiRequest(page, '/api/agents/agent/report', {
method: 'POST',
const updateRes = await apiRequest(page, "/api/agents/agent/report", {
method: "POST",
data: report2,
headers: { 'Content-Type': 'application/json' },
headers: { "Content-Type": "application/json" },
});
expect(res.ok()).toBeTruthy();
expect(updateRes.ok()).toBeTruthy();
// Verify lastSeen updated and metrics changed.
const stateRes2 = await apiRequest(page, '/api/state');
const stateRes2 = await apiRequest(page, "/api/state");
expect(stateRes2.ok()).toBeTruthy();
const state2 = (await stateRes2.json()) as Record<string, unknown>;
const host2 = findRegisteredAgentResource(state2);
expect(host2, 'Host should still exist after second report').toBeTruthy();
expect(host2, "Host should still exist after second report").toBeTruthy();
const lastSeen2 = readNumber(host2!.lastSeen);
expect(
lastSeen2 !== undefined && lastSeen1 !== undefined && lastSeen2 >= lastSeen1,
'lastSeen should be updated after second report',
lastSeen2 !== undefined &&
lastSeen1 !== undefined &&
lastSeen2 >= lastSeen1,
"lastSeen should be updated after second report",
).toBeTruthy();
// Verify metrics reflect the second report values.
const cpuAfter = readNumber(asRecord(host2!.cpu)?.current);
expect(
cpuAfter,
'CPU usage should reflect updated report (75%)',
readNumber(asRecord(host2!.cpu)?.current),
"CPU usage should reflect updated report (75%)",
).toBeGreaterThanOrEqual(70);
const memAfter = asRecord(host2!.memory);
expect(memAfter, 'Memory should be present after update').toBeTruthy();
const memUsageAfter = readNumber(memAfter?.current);
expect(memAfter, "Memory should be present after update").toBeTruthy();
expect(
memUsageAfter,
'Memory usage should reflect updated report (80%)',
readNumber(memAfter?.current),
"Memory usage should reflect updated report (80%)",
).toBeGreaterThanOrEqual(75);
});
test('host visible on infrastructure page', async (
{ page },
testInfo,
) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.skip(!hostRegistered, 'Host was not registered');
test.setTimeout(60_000);
await ensureAuthenticated(page);
// Ensure the host is registered in state before navigating to the UI.
await ensureHostRegistered(page);
await page.goto('/infrastructure', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/infrastructure/);
// Look for the exact test hostname on the page to avoid false positives
// from leftover hosts of prior runs.
const hostContent = page.getByText(TEST_HOSTNAME).first();
await page.goto("/standalone/machines", {
waitUntil: "domcontentloaded",
});
await expect(page).toHaveURL(/\/standalone\/machines/);
await expect(
hostContent,
`E2E test host "${TEST_HOSTNAME}" should be visible on the infrastructure page`,
page.getByText(TEST_DISPLAY_NAME).first(),
`E2E test host "${TEST_DISPLAY_NAME}" should be visible on the Machines page`,
).toBeVisible({ timeout: 30_000 });
});
test('host can be deleted via API', async ({ page }, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop agent journey',
);
test.skip(!hostRegistered, 'Host was not registered');
await ensureAuthenticated(page);
// Ensure host exists before attempting delete.
await ensureHostRegistered(page);
const delRes = await apiRequest(
page,
`/api/agents/agent/${serverAgentId}`,
{ method: 'DELETE' },
{ method: "DELETE" },
);
expect(
delRes.ok(),
`DELETE host failed: ${delRes.status()} ${await delRes.text()}`,
).toBeTruthy();
// Verify host is gone from state.
const stateRes = await apiRequest(page, '/api/state');
const stateRes = await apiRequest(page, "/api/state");
expect(stateRes.ok()).toBeTruthy();
const state = (await stateRes.json()) as Record<string, unknown>;
const found = findRegisteredAgentResource(state);
expect(
found,
'Host should be removed from state after deletion',
findRegisteredAgentResource(state),
"Host should be removed from state after deletion",
).toBeFalsy();
// Mark as cleaned up so afterAll doesn't try to delete again.
hostRegistered = false;
});
},
);
} finally {
if (hostRegistered && serverAgentId) {
const cleanupRes = await apiRequest(
page,
`/api/agents/agent/${serverAgentId}`,
{ method: "DELETE" },
).catch(() => undefined);
if (cleanupRes && !cleanupRes.ok()) {
console.warn(
`[journey cleanup] DELETE host ${serverAgentId} returned ${cleanupRes.status()}`,
);
}
}
}
});
});