diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 324ffa6a0..66abe257d 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -368,6 +368,7 @@ Companion drill: `go test ./internal/relay -run 'TestClient_E2E_MultiMobileClientRelay|TestClient_AbruptDisconnectCancelsInFlightHandlers|TestClient_AbruptDisconnectMultipleChannelCleanup|TestClient_DrainDuringInFlightData|TestClient_DrainWithMultipleInFlightChannels|TestClientRegister_SessionResumeRejectionClearsCachedSession|TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister' -count=1` `go test ./internal/api -run 'TestRelayEndpointsRequireLicenseFeature|TestRelayOnboardingEndpointsRequireLicenseFeature|TestRelayLicenseGatingResponseFormat|TestOnboardingQRPayloadStructure|TestOnboardingValidateSuccessAndFailure|TestOnboardingDeepLinkFormat' -count=1` `cd frontend-modern && npx vitest run src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx` + `cd tests/integration && PULSE_E2E_SKIP_DOCKER=1 PULSE_BASE_URL=http://127.0.0.1:7655 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4174 npm test -- tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts --project=chromium` `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts` - Manual scenario: 1. Register a fresh relay client. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2ab2ee541..e2a1b0347 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1090,7 +1090,10 @@ dashboard shell, while `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` owns license readiness, relay status polling, snooze state, and trial start runtime. Future onboarding changes must extend that split instead of pulling -license and relay runtime back into the card shell. +license and relay runtime back into the card shell. Live-route regressions in +that dashboard composition must be caught by +`tests/integration/tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts` +instead of relying only on isolated component tests. That relay pairing boundary now also includes backend-owned mobile credential lifecycle: when the settings surface generates a mobile pairing QR, it must ask the server for a fresh scoped Pulse Mobile relay access token, fetch the diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 767246b84..f4fc9379e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -206,6 +206,12 @@ regression protection. list hydration and must not turn dashboard landing on `frontend-modern/src/App.tsx` into another summary-fetch or org-bootstrap hot path. 31. Keep the dashboard overview hot path compact and route-owned. `frontend-modern/src/pages/Dashboard.tsx`, `frontend-modern/src/api/resources.ts`, and `frontend-modern/src/hooks/useDashboardOverview.ts` must hydrate KPI cards, problem-resource rows, and top-infrastructure identities through the compact dashboard-summary API contract owned by the adjacent `api-contracts` and `unified-resources` surfaces, rather than booting the full unfiltered paginated unified-resource list just to derive summary cards. + Commercial or relay-owned dashboard affordances such as + `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` may be + composed into that route, but they must remain additive shells on top of + the same compact summary payload instead of reintroducing route-local + `all-resources` fetches, summary recomputation, or page-level layout churn + that displaces the protected overview widgets. 32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. 33. Keep hidden workload-route selector shells off the hot path. When the workloads route keeps `frontend-modern/src/components/shared/InfrastructureSelector.tsx` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6c20eb869..b1d7e563b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -201,6 +201,12 @@ querying, and the operator-facing storage health presentation layer. they were already given, but they must not reopen paginated `useUnifiedResources()` transport or reintroduce per-pool `/api/metrics-store/history` fan-out under the dashboard hot path. + Route-owned dashboard additions such as + `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` may sit + above those cards, but they must remain adjacent composition only: the + relay surface must not replace the recovery/storage dashboard panels, + suppress the governed no-resources handoff, or move storage/recovery + summary ownership out of the compact dashboard route. 35. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by surface. Storage/recovery preview entrypoints such as `/preview/setup-complete` may remain public app-shell routes, but unrelated diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index a38ce1048..28069ef8d 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -31,6 +31,7 @@ import { TrendCharts, } from '@/features/dashboardOverview'; import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel'; +import { RelayOnboardingCard } from '@/components/Dashboard/RelayOnboardingCard'; import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel'; import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel'; import type { DashboardWidgetDef, DashboardWidgetId } from '@/features/dashboardOverview/dashboardWidgets'; @@ -271,6 +272,8 @@ export default function Dashboard() {
+ + {/* 1. Action Required Panel — only when actions exist */} vi.fn()); +const relayOnboardingCardSpy = vi.hoisted(() => vi.fn(() =>
)); const recoverySummaryMock: DashboardRecoverySummary = { totalProtected: 3, byOutcome: { success: 2, failed: 1 }, @@ -110,6 +111,10 @@ vi.mock('@/hooks/useDashboardRecovery', () => ({ useDashboardRecovery: () => () => recoverySummaryMock, })); +vi.mock('@/components/Dashboard/RelayOnboardingCard', () => ({ + RelayOnboardingCard: relayOnboardingCardSpy, +})); + describe('Dashboard page module contract', () => { beforeEach(() => { overviewLoading = false; @@ -118,6 +123,7 @@ describe('Dashboard page module contract', () => { wsReconnecting = false; reconnectSpy.mockReset(); navigateSpy.mockReset(); + relayOnboardingCardSpy.mockClear(); overviewMock.health.totalResources = 0; overviewMock.storage.total = 0; overviewMock.storage.totalCapacity = 0; @@ -139,6 +145,8 @@ describe('Dashboard page module contract', () => { it('routes dashboard overview panels through the dashboard overview feature owner', () => { expect(dashboardPageSource).toContain("from '@/features/dashboardOverview'"); + expect(dashboardPageSource).toContain("from '@/components/Dashboard/RelayOnboardingCard'"); + expect(dashboardPageSource).toContain(''); expect(dashboardPageSource).toContain( 'ActionRequiredPanel,\n DashboardCustomizer,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,', ); @@ -171,6 +179,7 @@ describe('Dashboard page module contract', () => { render(() => ); expect(screen.getByRole('heading', { name: 'No resources yet' })).toBeInTheDocument(); + expect(screen.queryByTestId('relay-onboarding-card')).toBeNull(); expect( screen.getByText( 'Start by opening Settings → Infrastructure → Install on a host and connecting the first system you want Pulse to monitor. Your dashboard overview will appear here once that system starts reporting.', @@ -192,6 +201,7 @@ describe('Dashboard page module contract', () => { render(() => ); + expect(screen.getByTestId('relay-onboarding-card')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Recovery Status' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Storage/ })).toBeInTheDocument(); expect(screen.getByText('Last recovery point over 24 hours ago')).toBeInTheDocument(); diff --git a/tests/integration/tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts b/tests/integration/tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts new file mode 100644 index 000000000..3f775debe --- /dev/null +++ b/tests/integration/tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts @@ -0,0 +1,91 @@ +import { expect, test, type Page } from '@playwright/test'; +import { ensureAuthenticated, getMockMode, setMockMode } from './helpers'; + +const FREE_RUNTIME_CAPABILITIES = { + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + hosted_mode: false, + max_history_days: 7, +}; + +async function waitForDashboardReady(page: Page) { + await expect(page).toHaveURL(/\/dashboard(?:\?.*)?$/); + await expect(page.getByTestId('dashboard-page')).toBeVisible(); + await page.waitForFunction( + () => !document.querySelector('[data-testid="dashboard-loading"]'), + undefined, + { timeout: 30_000 }, + ); +} + +test.describe.serial('Dashboard relay onboarding trial rate limit', () => { + test('shows Retry-After guidance on the live dashboard relay onboarding CTA', async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith('mobile-'), + 'Desktop-only dashboard relay onboarding coverage', + ); + + await page.route('**/api/license/runtime-capabilities', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(FREE_RUNTIME_CAPABILITIES), + }); + }); + + await page.route('**/api/license/trial/start', async (route) => { + await route.fulfill({ + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '120', + }, + body: JSON.stringify({ + code: 'trial_rate_limited', + error: 'Trial start rate limit exceeded', + details: { + retry_after_seconds: '45', + }, + }), + }); + }); + + await ensureAuthenticated(page); + + let initialMockMode: { enabled: boolean } | null = null; + try { + initialMockMode = await getMockMode(page); + if (!initialMockMode.enabled) { + await setMockMode(page, true); + } + } catch (error) { + console.warn(`[relay-onboarding] unable to read/set mock mode: ${String(error)}`); + } + + try { + await page.goto('/dashboard'); + await waitForDashboardReady(page); + + await expect(page.getByRole('heading', { name: 'Pair Your Mobile Device' })).toBeVisible(); + + const startTrialButton = page.getByRole('button', { name: /or start a pro trial/i }); + await expect(startTrialButton).toBeVisible(); + await startTrialButton.click(); + + await expect(page.getByText('Try again in about 2 minutes')).toBeVisible(); + await expect(page.getByText('Try again in about a minute')).toHaveCount(0); + } finally { + if (initialMockMode && !initialMockMode.enabled) { + try { + await setMockMode(page, false); + } catch (error) { + console.warn( + `[relay-onboarding] unable to restore mock mode, continuing: ${String(error)}`, + ); + } + } + } + }); +});