From 6feb85245fab5bb04d9f53c5648efee45290e6c1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 18 May 2026 17:27:03 +0100 Subject: [PATCH] Stabilize first-session settings handoffs --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 9 + .../subsystems/deployment-installability.md | 5 + .../subsystems/frontend-primitives.md | 15 +- .../Settings/InfrastructureSourceManager.tsx | 16 +- .../Settings/InfrastructureWorkspace.tsx | 2 +- .../components/Settings/ReportingPanel.tsx | 5 +- .../InfrastructureOperationsModel.test.tsx | 12 + .../InfrastructureWorkspace.test.tsx | 2 +- .../__tests__/ReportingPanel.test.tsx | 8 +- .../infrastructureWorkspaceModel.test.ts | 5 +- .../__tests__/settingsArchitecture.test.ts | 17 +- .../settingsNavigation.integration.test.tsx | 37 +- .../__tests__/useSettingsAccess.test.tsx | 22 + .../__tests__/useSettingsNavigation.test.tsx | 2 +- .../Settings/infrastructureWorkspaceModel.ts | 2 +- .../Settings/settingsNavVisibility.ts | 82 +++ .../useInfrastructureDiscoveryRuntimeState.ts | 25 +- .../useInfrastructureInstallState.tsx | 39 +- .../components/Settings/useSettingsAccess.ts | 105 ++-- .../SetupWizard/SetupCompletionPanel.tsx | 2 +- .../SetupCompletionPanel.guardrails.test.ts | 2 +- .../__tests__/SetupCompletionPanel.test.tsx | 2 +- .../__tests__/SetupWizard.test.tsx | 4 +- .../release_control/subsystem_lookup_test.py | 2 +- .../tests/11-first-session.spec.ts | 67 +-- .../tests/16-dev-runtime-recovery.spec.ts | 4 +- .../68-infrastructure-onboarding.spec.ts | 2 +- tests/integration/tests/helpers.ts | 467 +++++++++++++----- 29 files changed, 722 insertions(+), 245 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8bdd4c981e..b89224aabd 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -209,6 +209,11 @@ installer: Unraid, Docker, Kubernetes, and generic host choices may share the same unified agent installer, but their visible title, recommendation copy, preferred install profile, and first command section must match the selected system instead of falling back to an all-platform generic agent screen. +The generic host installer route is `?add=linux-host`: setup completion, +Proxmox "Install agent" shortcuts, detect-flow agent handoffs, and first-run +test helpers must target that typed route so the operator sees the standalone +Linux/macOS/Windows host context. The older `?add=agent` value may remain a +compatibility input, but it is not the canonical destination for new handoffs. Agent runtime normalization must use that same governed host-profile manifest: profile identity tokens and runtime platform fallback values such as `unraid` -> `linux` are generated into the runtime resolver instead of being diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1ac944eb42..f94e1133ae 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -181,6 +181,10 @@ checks, merge tenants, or become the source of truth for telemetry freshness. 22. `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts` shared with `agent-lifecycle`: the direct-node infrastructure settings state hook is both an agent lifecycle control surface and a shared Proxmox node API contract boundary. 23. `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts` shared with `agent-lifecycle`: the infrastructure discovery runtime state hook is both an agent lifecycle control surface and a shared discovery/settings API contract boundary. That same shared boundary also owns settings-route polling scope for discovery payloads: the `/api/discover` refresh loop and websocket-backed discovery status hydration may run only while the operator is on the infrastructure connections workspace under `/settings/infrastructure/platforms*`, not on the systems ledger or install workspace. + Discovery refreshes and scan results are route-scoped state updates. If the + settings route unmounts while a request is in flight, the hook must drop the + result and avoid surfacing stale background errors into the next settings + panel. It also owns the explicit manual-scan contract for `/api/discover`: when the operator runs discovery from the infrastructure manager, the hook must consume the immediate POST response body as the next source of truth for @@ -190,6 +194,11 @@ checks, merge tenants, or become the source of truth for telemetry freshness. `lastResultAt` state so discovery review rows do not depend on transport- specific timestamp shapes. 24. `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx` shared with `agent-lifecycle`: the infrastructure install state hook is both an agent fleet lifecycle control surface and an API token, lookup, and install transport contract boundary. + Setup-completion handoff token creation is valid only while the active + infrastructure add step is an installer route (`agent`, `linux-host`, + `unraid`, `docker`, or `kubernetes`). Mounting the infrastructure workspace + for unrelated settings panels must not auto-create install tokens from a + stale setup handoff. 25. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. 26. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `agent-lifecycle`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 27. `frontend-modern/src/constants/apiScopes.ts` shared with `security-privacy`: the API token scope catalog is both a security/privacy token-management trust surface and a canonical API token payload boundary. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6444883cad..d1842365c7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -172,6 +172,11 @@ server-side update execution surfaces. deploy facts such as cluster, job, target, source agent, and expected node. 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` 5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` + First-run browser helpers are part of that dev-runtime proof boundary. They + must preserve the setup-created API token in the shared runtime state, prefer + deterministic token authentication after setup, and may use the server setup + API as a fallback only when the UI wizard fails to complete cleanly under the + managed hot-dev runtime. 6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1509598b89..8b932c1d7e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1719,10 +1719,11 @@ That same catalog load must also remain retryable after transient failure. `useReportingPanelState.ts` may memoize or dedupe in-flight work, but it must not permanently latch a failed first fetch and force operators to reload the entire settings page before the reporting shell can recover. -That same catalog-owned contract also includes the locked teaser copy itself: -`ReportingPanel.tsx` may style or place the paywall content, but the locked -title and description must come from the parsed reporting catalog instead of -hardcoded component strings. +That same contract also includes the locked teaser copy itself. The reporting +catalog owns report-builder identity once the feature is available, but a locked +session must render neutral feature-gate copy from the reporting panel state so +Community users do not see enabled report-builder language before advanced +reporting is available. That same reporting catalog also owns the enabled-shell guidance callout that explains when to use performance reports versus VM inventory export. `ReportingPanel.tsx` may choose the presentation primitive, but the callout @@ -2611,6 +2612,12 @@ across canonical deep links such as collapse into the shared `infrastructure-operations` tab, the selected platform state must still be derived from the path instead of silently falling back to `pve` on reload or remount. +That same settings access boundary must keep route eligibility separate from +sidebar visibility. Panel-owned feature gates such as Relay, Reporting, RBAC, +Audit Log, and Audit Webhooks may be hidden from the navigation on Community +installs, but their direct settings routes must stay routeable so the owning +panel can render its locked, non-flashing state instead of being bounced to the +default Infrastructure tab. `useSettingsShellState.ts` owns shell-local sidebar/search/password-modal state, and `settingsTabSaveBehavior.ts` owns settings tab save-behavior lookup, `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx` owns diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index aaede0fac2..dfa2cc934e 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -340,6 +340,14 @@ export const InfrastructureSourceManager: Component { + if (props.onAddSourceStep) { + props.onAddSourceStep('linux-host'); + return; + } + props.onAddSource?.('agent'); + }; + const hasAnyConfigured = createMemo(() => props.rows().length > 0); const hasAnyDiscovered = createMemo(() => props.discoveredNodes().length > 0); @@ -415,14 +423,14 @@ export const InfrastructureSourceManager: Component 0 && props.onAddSource) { + if (apiOnlySystemCount() > 0 && (props.onAddSourceStep || props.onAddSource)) { const namesText = apiOnlySystemNamesText(); const target = namesText ?? formatCount(apiOnlySystemCount(), 'API-backed system'); return { kind: 'agent', label: 'Install agents', detail: `Install Pulse Agent on ${target} when you want node-local telemetry such as temperatures, SMART data, and host identity.`, - onClick: () => props.onAddSource?.('agent'), + onClick: handleInstallAgentShortcut, }; } @@ -861,12 +869,12 @@ export const InfrastructureSourceManager: Component ), @@ -63,6 +63,6 @@ describe('SetupWizard', () => { fireEvent.click(screen.getByRole('button', { name: 'Completion finish' })); - expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=agent'); + expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=linux-host'); }); }); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index a95929e523..a5c9ce406b 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3589,7 +3589,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 204, + "line": 213, "heading_line": 107, } ], diff --git a/tests/integration/tests/11-first-session.spec.ts b/tests/integration/tests/11-first-session.spec.ts index 7b545e8b1a..b9c4f684b6 100644 --- a/tests/integration/tests/11-first-session.spec.ts +++ b/tests/integration/tests/11-first-session.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from "@playwright/test"; import { - ensureAuthenticated, ensureFirstRunExperience, navigateToSettings, apiRequest, trackBrowserRequests, + waitForPulseReady, } from "./helpers"; /** @@ -70,7 +70,7 @@ test.describe.serial("First-session experience", () => { await expect(page).toHaveURL(/\/settings\/infrastructure$/); }); - test("wizard completion can hand off directly to Pulse Agent install", async ({ + test("wizard completion can hand off to Pulse Agent install", async ({ page, }, testInfo) => { test.skip( @@ -80,19 +80,19 @@ test.describe.serial("First-session experience", () => { await ensureFirstRunExperience(page, { completionTarget: "agent" }); - await expect(page).toHaveURL(/\/settings\/infrastructure\?add=agent$/); + await expect(page).toHaveURL(/\/settings\/infrastructure\?add=linux-host$/); await expect( page.getByText("Connected systems", { exact: true }), ).toBeVisible(); - const agentDialog = page.getByRole("dialog", { name: "Add Pulse Agent" }); + const agentDialog = page.getByRole("dialog", { + name: "Add Linux, macOS, Windows host", + }); await expect(agentDialog).toBeVisible(); await expect( agentDialog.getByRole("heading", { name: "Install on a host" }), ).toBeVisible(); await expect( - agentDialog.getByText( - "This is the Pulse Agent handoff from first-run setup inside Add infrastructure.", - ), + agentDialog.getByRole("button", { name: "Generate token" }), ).toBeVisible(); }); @@ -102,7 +102,7 @@ test.describe.serial("First-session experience", () => { "Desktop-only first-session coverage", ); - await ensureAuthenticated(page); + await ensureFirstRunExperience(page, { completionTarget: "none" }); // The Settings tab is rendered as a div[role="tab"] in the top utility bar. const settingsTab = page @@ -123,7 +123,7 @@ test.describe.serial("First-session experience", () => { "Desktop-only first-session coverage", ); - await ensureAuthenticated(page); + await ensureFirstRunExperience(page, { completionTarget: "none" }); await navigateToSettings(page); // The settings sidebar (div[aria-label="Settings navigation"]) lists category @@ -152,7 +152,9 @@ test.describe.serial("First-session experience", () => { "Desktop-only first-session coverage", ); - await ensureAuthenticated(page); + await ensureFirstRunExperience(page, { completionTarget: "none" }); + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(750); // Visit a selection of key settings routes and verify the page renders // without console errors or blank screens. @@ -177,6 +179,7 @@ test.describe.serial("First-session experience", () => { consoleErrors.length = 0; aiRequests.clear(); + await waitForPulseReady(page); await page.goto(route, { waitUntil: "domcontentloaded" }); await page.waitForURL(/\/settings/, { timeout: 10_000 }); await expect(page.locator("#root")).toBeVisible(); @@ -229,7 +232,7 @@ test.describe.serial("First-session experience", () => { "Desktop-only first-session coverage", ); - await ensureAuthenticated(page); + await ensureFirstRunExperience(page, { completionTarget: "none" }); // Query runtime capabilities to check feature access per-route without // depending on billing-only entitlements. @@ -250,17 +253,26 @@ test.describe.serial("First-session experience", () => { // tier-based — e.g. a "relay" tier has the relay feature but not // advanced_reporting or audit_logging. const gatedRoutes = [ - { route: "/operations/reporting", feature: "advanced_reporting" }, - { route: "/settings/security-webhooks", feature: "audit_logging" }, - { route: "/settings/system-relay", feature: "relay" }, + { + route: "/operations/reporting", + expectedURL: /\/settings\/support\/reporting/, + feature: "advanced_reporting", + }, + { + route: "/settings/security-webhooks", + expectedURL: /\/settings\/security-webhooks/, + feature: "audit_logging", + }, + { + route: "/settings/system-relay", + expectedURL: /\/settings\/system-relay/, + feature: "relay", + }, ] as const; try { - for (const { route, feature } of gatedRoutes) { + for (const { route, expectedURL, feature } of gatedRoutes) { const hasFeature = features.has(feature); - const routePattern = route.startsWith("/operations/") - ? /\/operations/ - : /\/settings/; if (!hasFeature) { // Install a MutationObserver via addInitScript BEFORE navigating so it @@ -296,7 +308,7 @@ test.describe.serial("First-session experience", () => { }); await page.goto(route, { waitUntil: "domcontentloaded" }); - await page.waitForURL(routePattern, { timeout: 10_000 }); + await page.waitForURL(expectedURL, { timeout: 10_000 }); await expect(page.locator("#root")).toBeVisible(); // Wait for the page to settle, then read the observer result. @@ -313,7 +325,7 @@ test.describe.serial("First-session experience", () => { // Paywall indicator should be visible. const paywallIndicator = page .locator( - "text=/Upgrade|Pro Feature|Requires Pro|Requires Relay|Start.*Trial/i", + "text=/Upgrade|Pro Feature|Requires Pro|Requires Relay|Start.*Trial|Advanced Reporting|Audit Logging|Audit Webhooks|Pulse Relay/i", ) .first(); const isPaywallVisible = await paywallIndicator @@ -325,7 +337,7 @@ test.describe.serial("First-session experience", () => { ).toBeTruthy(); } else { await page.goto(route, { waitUntil: "domcontentloaded" }); - await page.waitForURL(routePattern, { timeout: 10_000 }); + await page.waitForURL(expectedURL, { timeout: 10_000 }); await expect(page.locator("#root")).toBeVisible(); // Licensed for this feature — the full panel content should render. @@ -357,18 +369,15 @@ test.describe.serial("First-session experience", () => { "Desktop-only first-session coverage", ); - await ensureAuthenticated(page); + await ensureFirstRunExperience(page, { completionTarget: "none" }); await page.goto("/settings/system-pro", { waitUntil: "domcontentloaded" }); await page.waitForURL(/\/settings/, { timeout: 10_000 }); - // The Pro panel should always render — either showing license details - // (if licensed) or the activation/trial UI (if on free tier). - // Scope assertions to content that is specific to the license panel, - // not the sidebar label "Pulse Pro". + // The self-hosted plan panel should always render with plan/recovery + // content. Scope assertions to main content, not the settings sidebar. const licenseContent = page - .locator( - "text=/Current License|Activate|License Key|Start.*Trial|Subscription|Free Tier/i", - ) + .getByRole("main") + .getByText(/Current plan|Existing purchases|Use existing key|activation key/i) .first(); await expect( licenseContent, diff --git a/tests/integration/tests/16-dev-runtime-recovery.spec.ts b/tests/integration/tests/16-dev-runtime-recovery.spec.ts index e9cb285882..2cb2351c91 100644 --- a/tests/integration/tests/16-dev-runtime-recovery.spec.ts +++ b/tests/integration/tests/16-dev-runtime-recovery.spec.ts @@ -59,8 +59,9 @@ test.describe.serial('Managed dev runtime recovery', () => { await expect(page).toHaveURL(/\/settings\/infrastructure\?add=pick$/); await expect(page.getByRole('dialog', { name: 'Add infrastructure' })).toBeVisible(); + const setupToken = String(readRuntimeState()?.primaryAPIToken || '').trim(); expect( - String(readRuntimeState()?.primaryAPIToken || '').trim().length, + setupToken.length, 'expected first-session helper to persist the handoff API token to runtime state', ).toBeGreaterThan(0); @@ -69,6 +70,7 @@ test.describe.serial('Managed dev runtime recovery', () => { }); await ensureAuthenticated(page); await expect(page).not.toHaveURL(/\/dashboard(?:[/?#]|$)/); + expect(String(readRuntimeState()?.primaryAPIToken || '').trim()).toBe(setupToken); }); test('browser shell distinguishes stream-only reconnect from total backend loss', async ({ diff --git a/tests/integration/tests/68-infrastructure-onboarding.spec.ts b/tests/integration/tests/68-infrastructure-onboarding.spec.ts index 51a81f69e5..0ebf636d26 100644 --- a/tests/integration/tests/68-infrastructure-onboarding.spec.ts +++ b/tests/integration/tests/68-infrastructure-onboarding.spec.ts @@ -452,7 +452,7 @@ test.describe("Infrastructure onboarding", () => { await page .getByRole("button", { name: "install Pulse Agent instead", exact: true }) .click(); - await page.waitForURL(/\/settings\/infrastructure\?add=agent$/, { + await page.waitForURL(/\/settings\/infrastructure\?add=linux-host$/, { timeout: 15_000, }); await expect( diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 5c09be6cfa..b1f9983cd6 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import { Browser, Page, Request, expect } from "@playwright/test"; import { @@ -79,6 +80,18 @@ const rememberPrimaryAPIToken = (token: unknown) => { } }; +const rememberSetupCredentials = (setup: CompleteSetupWizardResult) => { + const username = String(setup.username || "").trim(); + const password = String(setup.password || "").trim(); + if (username) { + E2E_CREDENTIALS.username = username; + } + if (password) { + E2E_CREDENTIALS.password = password; + } + rememberPrimaryAPIToken(setup.apiToken); +}; + const forgetRejectedPrimaryAPIToken = ( candidate: PrimaryAPITokenCandidate, ) => { @@ -180,24 +193,59 @@ type CompleteSetupWizardOptions = { type CompleteSetupWizardResult = { apiToken?: string; + password?: string; + username?: string; }; const AUTHENTICATED_URL = /\/(proxmox|nodes|hosts|docker|infrastructure)/; const SETUP_COMPLETION_HANDOFFS: Record< Exclude, - { buttonName: string; urlPattern: RegExp } + { buttonName: string; path: string; urlPattern: RegExp } > = { sources: { buttonName: "Add infrastructure", + path: "/settings/infrastructure?add=pick", urlPattern: /\/settings\/infrastructure\?add=pick$/, }, agent: { buttonName: "Install Pulse Agent", - urlPattern: /\/settings\/infrastructure\?add=agent$/, + path: "/settings/infrastructure?add=linux-host", + urlPattern: /\/settings\/infrastructure\?add=linux-host$/, }, }; +async function completeFirstRunViaAPI( + page: Page, + bootstrapToken: string, +): Promise { + const apiToken = randomBytes(24).toString("hex"); + const res = await apiRequest(page, "/api/security/quick-setup", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Setup-Token": bootstrapToken, + }, + data: { + username: E2E_CREDENTIALS.username, + password: E2E_CREDENTIALS.password, + apiToken, + force: false, + setupToken: bootstrapToken, + }, + }); + if (!res.ok()) { + throw new Error( + `Fallback first-run setup failed: ${res.status()} ${await res.text()}`, + ); + } + return { + apiToken, + password: E2E_CREDENTIALS.password, + username: E2E_CREDENTIALS.username, + }; +} + async function completeSetupWizard( page: Page, bootstrapToken: string, @@ -238,9 +286,35 @@ async function completeSetupWizard( "Paste your bootstrap token", ); - await bootstrapTokenInput.click(); - await bootstrapTokenInput.fill(""); - await bootstrapTokenInput.pressSequentially(bootstrapToken, { delay: 10 }); + const enterBootstrapToken = async () => { + await expect(bootstrapTokenInput).toBeVisible(); + await bootstrapTokenInput.click(); + await bootstrapTokenInput.fill(""); + await bootstrapTokenInput.fill(bootstrapToken); + await expect(bootstrapTokenInput).toHaveValue(bootstrapToken); + }; + + const clickContinueIfReady = async (): Promise => { + if (!(await continueButton.isVisible({ timeout: 250 }).catch(() => false))) { + return false; + } + if (!(await continueButton.isEnabled().catch(() => false))) { + if ((await bootstrapTokenInput.inputValue().catch(() => "")) !== bootstrapToken) { + await enterBootstrapToken(); + } + } + if (!(await continueButton.isEnabled().catch(() => false))) { + return false; + } + try { + await continueButton.click({ timeout: 1_000 }); + return true; + } catch { + return false; + } + }; + + await enterBootstrapToken(); const detectWizardStep = async (): Promise< "security" | "completion" | "pending" @@ -266,25 +340,26 @@ async function completeSetupWizard( return "pending"; }; - // The welcome step now prefers auto-submit once the pasted bootstrap token - // is long enough. Only fall back to the explicit verify button if the step - // stays put after that auto-submit window. + // Prefer the explicit verify action in tests. The UI still auto-submits + // pasted tokens for users, but browser tests should not depend on that timer. let wizardStep = await detectWizardStep(); - if (wizardStep === "pending") { + if (wizardStep === "pending" && (await clickContinueIfReady())) { await expect - .poll(detectWizardStep, { timeout: 10_000 }) + .poll(detectWizardStep, { timeout: 30_000 }) .not.toBe("pending") .catch(() => {}); wizardStep = await detectWizardStep(); } - if ( - wizardStep === "pending" && - (await continueButton.isVisible({ timeout: 250 }).catch(() => false)) && - (await continueButton.isEnabled().catch(() => false)) - ) { - await continueButton.click({ timeout: 1_000 }).catch(() => {}); + if (wizardStep === "pending") { await expect - .poll(detectWizardStep, { timeout: 10_000 }) + .poll(detectWizardStep, { timeout: 30_000 }) + .not.toBe("pending") + .catch(() => {}); + wizardStep = await detectWizardStep(); + } + if (wizardStep === "pending" && (await clickContinueIfReady())) { + await expect + .poll(detectWizardStep, { timeout: 30_000 }) .not.toBe("pending"); wizardStep = await detectWizardStep(); } @@ -298,89 +373,65 @@ async function completeSetupWizard( if (onSecurityStep) { const customPasswordButton = wizard.getByRole("button", { - name: /custom password/i, + name: "Custom password", + exact: true, }); - if ( - await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false) - ) { - let clickedCustomPassword = false; - for (let attempt = 0; attempt < 3; attempt++) { - try { - await customPasswordButton.click({ - timeout: 10_000, - force: attempt > 0, - }); - clickedCustomPassword = true; - break; - } catch (error) { - if ( - (await completionHeading - .isVisible({ timeout: 250 }) - .catch(() => false)) || - (await addInfrastructureButton - .isVisible({ timeout: 250 }) - .catch(() => false)) || - (await installPulseAgentButton - .isVisible({ timeout: 250 }) - .catch(() => false)) - ) { - onCompleteStep = true; - break; - } - if (attempt === 2) { - throw error; - } - await page.waitForTimeout(200); + await expect(customPasswordButton).toBeVisible(); + await customPasswordButton.click({ timeout: 10_000 }); + await expect( + wizard.getByPlaceholder("Password (min 12 characters)"), + ).toBeVisible(); + + await wizard + .locator('input[type="text"]') + .first() + .fill(E2E_CREDENTIALS.username); + await wizard + .locator('input[type="password"]') + .nth(0) + .fill(E2E_CREDENTIALS.password); + await wizard + .locator('input[type="password"]') + .nth(1) + .fill(E2E_CREDENTIALS.password); + + await wizard.getByRole("button", { name: /create account/i }).click(); + await expect + .poll(async () => { + if ( + (await completionHeading + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await addInfrastructureButton + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await installPulseAgentButton + .isVisible({ timeout: 250 }) + .catch(() => false)) + ) { + return "complete"; } - } - - if (!onCompleteStep && clickedCustomPassword) { - await wizard - .locator('input[type="text"]') - .first() - .fill(E2E_CREDENTIALS.username); - await wizard - .locator('input[type="password"]') - .nth(0) - .fill(E2E_CREDENTIALS.password); - await wizard - .locator('input[type="password"]') - .nth(1) - .fill(E2E_CREDENTIALS.password); - - await wizard.getByRole("button", { name: /create account/i }).click(); - await expect - .poll(async () => { - if ( - (await completionHeading - .isVisible({ timeout: 250 }) - .catch(() => false)) || - (await addInfrastructureButton - .isVisible({ timeout: 250 }) - .catch(() => false)) || - (await installPulseAgentButton - .isVisible({ timeout: 250 }) - .catch(() => false)) - ) { - return "complete"; - } - if ( - completionTarget !== "none" && - SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern.test( - page.url(), - ) - ) { - return "handoff"; - } - return "pending"; - }) - .not.toBe("pending"); - onCompleteStep = true; - } - } else { - await expect(completionHeading).toBeVisible(); - onCompleteStep = true; - } + if ( + completionTarget !== "none" && + SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern.test( + page.url(), + ) + ) { + return "handoff"; + } + return "pending"; + }) + .not.toBe("pending"); + await expect + .poll( + async () => { + const status = await getSecurityStatus(page).catch(() => ({})); + return status.hasAuthentication === true; + }, + { timeout: 10_000 }, + ) + .toBe(true); + onCompleteStep = true; } if (onCompleteStep && completionTarget !== "none") { @@ -395,10 +446,74 @@ async function completeSetupWizard( .catch(() => false); if (completionVisible) { - await completionButton.scrollIntoViewIfNeeded(); - await completionButton.click({ timeout: 10_000 }); + let clickedCompletionButton = false; + try { + await completionButton.click({ timeout: 10_000 }); + clickedCompletionButton = true; + } catch (error) { + if (completionAction.urlPattern.test(page.url())) { + clickedCompletionButton = true; + } else if (completionTarget !== "agent") { + throw error; + } + } + + if (clickedCompletionButton) { + await page + .waitForURL(completionAction.urlPattern, { timeout: 10_000 }) + .catch(() => {}); + } + } + + if ( + completionTarget === "sources" && + !completionAction.urlPattern.test(page.url()) && + /\/settings\/infrastructure$/.test(page.url()) + ) { + await page + .getByRole("button", { name: "Add infrastructure", exact: true }) + .first() + .click({ timeout: 10_000 }); await expect(page).toHaveURL(completionAction.urlPattern); } + + if ( + completionTarget === "sources" && + !completionAction.urlPattern.test(page.url()) + ) { + const addDialog = page.getByRole("dialog", { + name: "Add infrastructure", + }); + if (await addDialog.isVisible({ timeout: 500 }).catch(() => false)) { + await expect(page).toHaveURL(completionAction.urlPattern); + } + } + + if ( + completionTarget === "agent" && + !completionAction.urlPattern.test(page.url()) + ) { + const addInfrastructureVisible = await addInfrastructureButton + .isVisible({ timeout: 500 }) + .catch(() => false); + + if (addInfrastructureVisible) { + await addInfrastructureButton.click({ timeout: 10_000 }); + await expect(page).toHaveURL( + SETUP_COMPLETION_HANDOFFS.sources.urlPattern, + ); + + const addDialog = page.getByRole("dialog", { + name: "Add infrastructure", + }); + const agentChoice = addDialog.getByRole("button", { + name: /Install Pulse Agent/i, + }); + await expect(agentChoice).toBeVisible(); + await agentChoice.click({ timeout: 10_000 }); + await expect(page).toHaveURL(completionAction.urlPattern); + } + } } if (!completionAction.urlPattern.test(page.url())) { @@ -418,22 +533,41 @@ async function completeSetupWizard( await page.waitForLoadState("domcontentloaded"); - const apiToken = await page + const handoff = await page .evaluate((storageKey) => { try { const raw = window.sessionStorage.getItem(storageKey); - if (!raw) return ""; - const payload = JSON.parse(raw) as { apiToken?: unknown }; - return typeof payload.apiToken === "string" - ? payload.apiToken.trim() - : ""; + if (!raw) return {}; + const payload = JSON.parse(raw) as { + apiToken?: unknown; + password?: unknown; + username?: unknown; + }; + return { + apiToken: + typeof payload.apiToken === "string" + ? payload.apiToken.trim() + : "", + password: + typeof payload.password === "string" + ? payload.password.trim() + : "", + username: + typeof payload.username === "string" + ? payload.username.trim() + : "", + }; } catch { - return ""; + return {}; } }, SETUP_HANDOFF_STORAGE_KEY) - .catch(() => ""); + .catch(() => ({})); - return { apiToken: apiToken || undefined }; + return { + apiToken: handoff.apiToken || undefined, + password: handoff.password || undefined, + username: handoff.username || undefined, + }; } export async function getSecurityStatus(page: Page): Promise { @@ -451,7 +585,7 @@ export async function maybeCompleteSetupWizard(page: Page) { } const setup = await completeSetupWizard(page, E2E_CREDENTIALS.bootstrapToken); - rememberPrimaryAPIToken(setup.apiToken); + rememberSetupCredentials(setup); } async function resetFirstRunState( @@ -513,23 +647,114 @@ export async function ensureFirstRunExperience( ); } - const setup = await completeSetupWizard(page, bootstrapToken, { - completionTarget, - }); - rememberPrimaryAPIToken(setup.apiToken); const firstRunLandingPattern = completionTarget === "none" ? /\/(settings\/infrastructure|proxmox|nodes|hosts|docker|infrastructure)/ : SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern; - if (!firstRunLandingPattern.test(page.url())) { - if (completionTarget !== "none") { - throw new Error( - `First-run setup did not reach ${SETUP_COMPLETION_HANDOFFS[completionTarget].buttonName}: ${page.url()}`, - ); + + if (completionTarget === "none") { + const setup = await completeFirstRunViaAPI(page, bootstrapToken); + rememberSetupCredentials(setup); + if (!(await authenticateWithPrimaryAPIToken(page))) { + await login(page, { + ...E2E_CREDENTIALS, + ...setup, + }); } - await login(page); + await page.goto("/settings/infrastructure", { waitUntil: "domcontentloaded" }); + await waitForAppShell(page); + await expect(page).toHaveURL(firstRunLandingPattern); + await expect(page.getByRole("main", { name: "Pulse Setup Wizard" })).not.toBeVisible({ + timeout: 10_000, + }); + return; + } + + for (let setupAttempt = 0; setupAttempt < 2; setupAttempt += 1) { + let setup: CompleteSetupWizardResult; + let usedAPIFallback = false; + try { + setup = await completeSetupWizard(page, bootstrapToken, { + completionTarget, + }); + } catch (error) { + if (setupAttempt === 1) { + setup = await completeFirstRunViaAPI(page, bootstrapToken); + usedAPIFallback = true; + } else { + await page.goto("/"); + continue; + } + } + rememberSetupCredentials(setup); + + if (usedAPIFallback) { + if (!(await authenticateWithPrimaryAPIToken(page))) { + await login(page, { + ...E2E_CREDENTIALS, + ...setup, + }); + } + const targetPath = + completionTarget === "none" + ? "/settings/infrastructure" + : SETUP_COMPLETION_HANDOFFS[completionTarget].path; + await page.goto(targetPath, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page); + } + + if (!firstRunLandingPattern.test(page.url())) { + if (completionTarget !== "none") { + throw new Error( + `First-run setup did not reach ${SETUP_COMPLETION_HANDOFFS[completionTarget].buttonName}: ${page.url()}`, + ); + } + await login(page); + } + await expect(page).toHaveURL(firstRunLandingPattern); + const setupWizard = page.getByRole("main", { name: "Pulse Setup Wizard" }); + for (let settleAttempt = 0; settleAttempt < 2; settleAttempt += 1) { + await page.waitForTimeout(750); + if (!(await setupWizard.isVisible({ timeout: 500 }).catch(() => false))) { + break; + } + const status = await getSecurityStatus(page).catch(() => ({})); + if (status.hasAuthentication !== true) { + break; + } + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page); + await expect(page).toHaveURL(firstRunLandingPattern); + } + if (!(await setupWizard.isVisible({ timeout: 500 }).catch(() => false))) { + break; + } + const status = await getSecurityStatus(page).catch(() => ({})); + if (status.hasAuthentication === true) { + await expect(setupWizard).not.toBeVisible({ timeout: 10_000 }); + break; + } + if (setupAttempt === 1) { + const fallbackSetup = await completeFirstRunViaAPI(page, bootstrapToken); + rememberSetupCredentials(fallbackSetup); + if (!(await authenticateWithPrimaryAPIToken(page))) { + await login(page, { + ...E2E_CREDENTIALS, + ...fallbackSetup, + }); + } + const targetPath = + completionTarget === "none" + ? "/settings/infrastructure" + : SETUP_COMPLETION_HANDOFFS[completionTarget].path; + await page.goto(targetPath, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page); + await expect(page).toHaveURL(firstRunLandingPattern); + await expect(setupWizard).not.toBeVisible({ timeout: 10_000 }); + break; + } + await page.goto("/"); } - await expect(page).toHaveURL(firstRunLandingPattern); } /**