diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 95e511f894..5d4cb4c2ad 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -437,10 +437,11 @@ jobs: working-directory: tests/integration run: node scripts/report-stable-e2e-failures.mjs test-results/junit.xml - # Preserve the completed stable failure before observational work can - # reach the job deadline. Probation must not take its receipts with it. + # Preserve failed attempts, including a passing first retry, before + # probation can reach the job deadline. Retry traces and failure images + # are diagnostic bytes even when the suite's final outcome is success. - name: Upload Playwright report - if: ${{ !cancelled() && steps.stable.outcome == 'failure' }} + if: ${{ !cancelled() && (steps.stable.outcome == 'failure' || hashFiles('tests/integration/test-results/**/trace.zip', 'tests/integration/test-results/**/test-failed-*.png') != '') }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: playwright-report-shard-${{ matrix.shard }} @@ -450,7 +451,7 @@ jobs: # The HTML report already contains screenshots, videos and traces. # Keep only the separate machine-readable result here. - name: Upload JUnit results - if: ${{ !cancelled() && steps.stable.outcome == 'failure' }} + if: ${{ !cancelled() && (steps.stable.outcome == 'success' || steps.stable.outcome == 'failure') }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-results-shard-${{ matrix.shard }} @@ -516,7 +517,7 @@ jobs: # continue-on-error keeps the job green on a probation failure, so # failure() never fires for these; key off the step outcome instead. - name: Upload probation Playwright report - if: ${{ !cancelled() && steps.probation.outcome == 'failure' }} + if: ${{ !cancelled() && (steps.probation.outcome == 'failure' || hashFiles('tests/integration/test-results-probation/**/trace.zip', 'tests/integration/test-results-probation/**/test-failed-*.png') != '') }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: playwright-report-probation-shard-${{ matrix.shard }} @@ -524,7 +525,7 @@ jobs: retention-days: 3 - name: Upload probation JUnit results - if: ${{ !cancelled() && steps.probation.outcome == 'failure' }} + if: ${{ !cancelled() && (steps.probation.outcome == 'success' || steps.probation.outcome == 'failure') }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-results-probation-shard-${{ matrix.shard }} diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 591e36a1c5..f04ad7c148 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,24 @@ ## Purpose +### Unavailable Proxmox node disks are not recovered alerts + +The node evaluator admits the same disk observations as the canonical node +metric: positive capacity, finite percentage in range, and non-negative used +space within capacity. Missing or contradictory readings hold an open disk +occurrence with its acknowledgement and trusted evidence, and interrupt pending +activation and explicit intent grace. An observed zero still recovers normally. +Explicit disabled/off policy and an actually covering linked agent still release +the node alert; a missing reading does not claim another owner. Independent CPU, +memory and temperature evaluation and the existing offline policy are unchanged. + +`node_disk_observation_test.go` exercises CheckNode, lifecycle events, measured +zero, both grace paths, exact-node isolation, explicit policy and real linked-agent +handover. Its optional manager frames feed the production Alerts API adapter and +Overview at desktop/narrow viewports. This aligns observation admission only: no +collector, guest operation, schema, historical rewrite or notification transport +changes, and no installed recovery or inbox claim. + ### Unavailable agent filesystems are not recovered disk alerts Host filesystem thresholds use the canonical disk-observation admission: a diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5bbbf7f4fa..74f2c21ff0 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3027,10 +3027,22 @@ artifact-selection behaviour. That regression creates and revokes its own token through the admitted session and shared browser-token helper. It must work with cookie-only and primary-token fixtures without replacing their remembered credential. + Protected full-page navigations also await that mounted-shell boundary + before judging a consuming panel. A loading root is not a ready workspace. + The delayed-token browser regression owns a fresh page and holds only that + page's bootstrap reads, so reads from a departing document cannot release + or stall its control. Responsive threshold edits leave the whole desktop + row or use the card's Save control before asserting a staged change. The Core E2E Docker stack explicitly sets bounded development-only general and update request budgets for isolated browser contexts sharing one bridge IP. Production limits and authentication limits remain enforced. The API contracts subsystem owns validation and exhaustion semantics. + Core E2E retains a tier's HTML diagnostic report when it fails or produces + a first-retry trace/failure screenshot, including a retry that passes. Small + JUnit results are retained for every completed tier. Stable receipts are + uploaded before probation starts, and both tiers keep the existing three-day + retention. Artifact presence controls retention, not release readiness or + probation promotion. Settings browser audits scroll the actual application content owner to its observed bottom. Traversal must scale with the populated estate rather than stop after a fixed number of wheel steps. The browser geometry regression diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index a78ad933bd..5f5bff62a6 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -15870,6 +15870,8 @@ func TestCheckNode(t *testing.T) { Instance: "pve1", Status: "online", Disk: models.Disk{ + Total: 100, + Used: 95, Usage: 95.0, // Above trigger }, } diff --git a/internal/alerts/node.go b/internal/alerts/node.go index 00db8a2ba2..d187fd2da7 100644 --- a/internal/alerts/node.go +++ b/internal/alerts/node.go @@ -119,8 +119,13 @@ func (m *Manager) CheckNode(node models.Node) { } if owners.disk.disk { released = append(released, "disk") - } else { - input.Disk = &UnifiedResourceMetric{Percent: node.Disk.Usage} + } else if percent, observed := unifiedresources.DiskUsagePercent(node.Disk); observed { + input.Disk = &UnifiedResourceMetric{Percent: percent} + } else if !liveHysteresisThreshold(thresholds.Disk) { + // Explicit policy still releases the alert, even without a + // reading. Otherwise leave it unknown: missing capacity or + // contradictory used space is not measured zero/recovery. + input.Disk = &UnifiedResourceMetric{} } m.releaseNodeMetricAlerts(node, owners, released...) m.evaluateUnifiedMetrics(input, thresholds, nil, "temperature") // checkNodeTemperature owns its observation gaps. diff --git a/internal/alerts/node_disk_observation_test.go b/internal/alerts/node_disk_observation_test.go new file mode 100644 index 0000000000..99f888dfb8 --- /dev/null +++ b/internal/alerts/node_disk_observation_test.go @@ -0,0 +1,269 @@ +package alerts + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts/reducer" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func nodeDiskManager(t *testing.T, intent bool) (*Manager, *atomic.Int64) { + t.Helper() + m, clock := continuityManager(t, intent) + m.mu.Lock() + m.config.NodeDefaults = ThresholdConfig{Disk: &HysteresisThreshold{Trigger: 80, Clear: 70}} + if !intent { + m.config.MetricTimeThresholds = map[string]map[string]int{"all": {"disk": 0}} + } + m.mu.Unlock() + return m, clock +} + +func nodeDiskSample(id string, usage float64) models.Node { + return models.Node{ID: id, Name: "pve", Instance: "site", Status: "online", Disk: models.Disk{Total: 1000, Used: int64(usage * 10), Usage: usage}} +} + +func nodeDiskGap(node models.Node, gap string) models.Node { + switch gap { + case "missing": + node.Disk = models.Disk{} + case "capacity-only": + node.Disk = models.Disk{Total: 1000, Used: -1} + case "no-capacity": + node.Disk.Total = 0 + case "negative-capacity": + node.Disk.Total = -1 + case "over-capacity": + node.Disk.Used = 1001 + case "negative": + node.Disk.Usage = -1 + case "over-100": + node.Disk.Usage = 101 + case "NaN": + node.Disk.Usage = math.NaN() + case "+Inf": + node.Disk.Usage = math.Inf(1) + } + return node +} + +func nodeDiskJSON(t *testing.T, value any) []byte { + t.Helper() + b, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestNodeDiskUnknownPreservesOccurrence(t *testing.T) { + for _, gap := range []string{"missing", "capacity-only", "no-capacity", "negative-capacity", "over-capacity", "negative", "over-100", "NaN", "+Inf"} { + t.Run(gap, func(t *testing.T) { + m, clock := nodeDiskManager(t, false) + node := nodeDiskSample("site-pve", 95) + m.CheckNode(node) + if len(m.GetActiveAlerts()) != 1 { + t.Fatal("known high disk must fire") + } + id := m.GetActiveAlerts()[0].ID + if err := m.AcknowledgeAlert(id, "operator"); err != nil { + t.Fatal(err) + } + before := nodeDiskJSON(t, m.GetActiveAlerts()) + events := len(queryAlertEvents(t, m, eventlog.Filter{})) + clock.Store(int64(2 * time.Minute)) + for range 3 { + m.CheckNode(nodeDiskGap(node, gap)) + } + if !bytes.Equal(before, nodeDiskJSON(t, m.GetActiveAlerts())) || len(queryAlertEvents(t, m, eventlog.Filter{})) != events { + t.Fatal("unknown node disk changed occurrence, acknowledgement, trusted evidence or lifecycle") + } + m.CheckNode(nodeDiskSample(node.ID, 0)) + if len(m.GetActiveAlerts()) != 0 || len(queryAlertEvents(t, m, eventlog.Filter{AlertID: id, Types: []string{eventlog.TypeResolved}})) != 1 { + t.Fatal("measured zero must resolve the original once") + } + }) + } +} + +func TestNodeDiskUnknownNeverStartsAnIncident(t *testing.T) { + for _, gap := range []string{"missing", "capacity-only", "no-capacity", "negative-capacity", "over-capacity", "negative", "over-100", "NaN", "+Inf"} { + t.Run(gap, func(t *testing.T) { + m, clock := nodeDiskManager(t, false) + node := nodeDiskGap(nodeDiskSample("site-pve", 95), gap) + m.CheckNode(node) + clock.Store(int64(time.Hour)) + m.CheckNode(node) + if len(m.GetActiveAlerts()) != 0 || len(queryAlertEvents(t, m, eventlog.Filter{})) != 0 { + t.Fatal("unavailable node disk invented an incident") + } + }) + } +} + +func TestNodeDiskGapRestartsActivation(t *testing.T) { + for _, intent := range []bool{false, true} { + for _, gap := range []string{"missing", "no-capacity", "over-capacity"} { + t.Run(fmt.Sprintf("intent=%v/%s", intent, gap), func(t *testing.T) { + m, clock := nodeDiskManager(t, intent) + m.mu.Lock() + m.config.MetricTimeThresholds = map[string]map[string]int{"all": {"disk": 60}} + m.mu.Unlock() + node := nodeDiskSample("site-pve", 85) + other := nodeDiskSample("other-pve", 85) + m.CheckNode(node) + m.CheckNode(other) + incident, exists := continuityIncident(m, node.ID, "disk") + if !exists || incident.State != reducer.StatePending { + t.Fatal("control must create timed activation") + } + clock.Store(int64(2 * time.Minute)) + m.CheckNode(nodeDiskGap(node, gap)) + if _, exists := continuityIncident(m, node.ID, "disk"); exists { + t.Fatal("unknown retained timed disk evidence") + } + if _, exists := continuityIncident(m, other.ID, "disk"); !exists { + t.Fatal("gap crossed exact node identity") + } + m.CheckNode(node) + clock.Store(int64(2*time.Minute + 59*time.Second)) + m.CheckNode(node) + if len(m.GetActiveAlerts()) != 0 { + t.Fatal("post-gap run completed early") + } + clock.Store(int64(3 * time.Minute)) + m.CheckNode(node) + if len(m.GetActiveAlerts()) != 1 { + t.Fatal("full fresh run did not fire") + } + if m.ShadowDivergences() != 0 { + t.Fatal("core and shadow diverged") + } + }) + } + } +} + +func TestNodeDiskUnknownHonoursExplicitPolicy(t *testing.T) { + for _, policy := range []string{"nil-threshold", "zero-threshold", "override", "node-disabled", "all-nodes-disabled"} { + for _, gap := range []string{"missing", "NaN"} { + t.Run(policy+"/"+gap, func(t *testing.T) { + m, _ := nodeDiskManager(t, false) + node := nodeDiskSample("site-pve", 95) + m.CheckNode(node) + if len(m.GetActiveAlerts()) != 1 { + t.Fatal("known disk control must fire") + } + // Direct changes exercise CheckNode's policy handling, not UpdateConfig cleanup. + m.mu.Lock() + switch policy { + case "nil-threshold": + m.config.NodeDefaults.Disk = nil + case "zero-threshold": + m.config.NodeDefaults.Disk = &HysteresisThreshold{} + case "override": + m.config.Overrides[node.ID] = ThresholdConfig{Disk: &HysteresisThreshold{}} + case "node-disabled": + m.config.Overrides[node.ID] = ThresholdConfig{Disabled: true} + case "all-nodes-disabled": + m.config.DisableAllNodes = true + } + m.mu.Unlock() + m.CheckNode(nodeDiskGap(node, gap)) + if len(m.GetActiveAlerts()) != 0 { + t.Fatal("unavailable disk overrode explicit alert policy") + } + }) + } + } +} + +func TestNodeDiskUnknownLeavesIndependentMetricsAndAgentHandover(t *testing.T) { + m, clock := nodeDiskManager(t, false) + m.mu.Lock() + m.config.NodeDefaults.CPU = &HysteresisThreshold{Trigger: 80, Clear: 70} + m.config.NodeDefaults.Memory = &HysteresisThreshold{Trigger: 80, Clear: 70} + m.config.MetricTimeThresholds = map[string]map[string]int{"all": {"disk": 0, "cpu": 0, "memory": 0}} + m.config.MinimumDelta = 2 + m.config.SuppressionWindow = 5 + m.mu.Unlock() + node := nodeDiskSample("site-pve", 95) + m.CheckNode(node) + gap := nodeDiskGap(node, "missing") + gap.CPU = .95 + gap.Memory = models.Memory{Total: 1000, Used: 950, Usage: 95} + m.CheckNode(gap) + if len(m.GetActiveAlerts()) != 3 { + t.Fatal("disk gap masked independently observed CPU or memory") + } + // A real linked agent still takes over its disk; absence is not another owner. + host := hostFilesystemSample("host-node-disk", 95) + host.LinkedNodeID = node.ID + m.mu.Lock() + m.config.AgentDefaults = ThresholdConfig{Disk: &HysteresisThreshold{Trigger: 80, Clear: 70}} + m.mu.Unlock() + m.CheckHost(host) + m.CheckNode(gap) + if testHasActiveAlert(t, m, canonicalMetricStateID(node.ID, "disk")) { + t.Fatal("real agent handover left duplicate node disk") + } + if !testHasActiveAlert(t, m, canonicalMetricStateID(hostFilesystemID(host), "disk")) { + t.Fatal("handover lost agent disk incident") + } + m.HandleHostRemoved(host) + // These are later reports, not a replay of the original observation. + clock.Store(int64(time.Second)) + m.CheckNode(gap) + if testHasActiveAlert(t, m, canonicalMetricStateID(node.ID, "disk")) { + t.Fatal("removing agent invented a node reading") + } + // The existing 2% minimum delta suppresses a new occurrence at the same + // 95% within five minutes. Use a genuinely changed measured value; this + // control tests restored node evidence, not bypassing duplicate policy. + node.Disk = nodeDiskSample(node.ID, 98).Disk + clock.Add(int64(time.Second)) + m.CheckNode(node) + if !testHasActiveAlert(t, m, canonicalMetricStateID(node.ID, "disk")) { + t.Fatal("fresh node disk did not regain ownership") + } +} + +// Export only opt-in synthetic manager frames for the real consuming UI proof. +func TestNodeDiskBrowserProducer(t *testing.T) { + m, clock := nodeDiskManager(t, false) + node := nodeDiskSample("site-browser-pve", 95) + frames := map[string]json.RawMessage{} + for _, phase := range []string{"breach", "missing", "over-capacity", "recovered"} { + sample := node + if phase == "recovered" { + sample = nodeDiskSample(node.ID, 0) + } else if phase != "breach" { + sample = nodeDiskGap(node, phase) + } + clock.Add(int64(time.Second)) + m.CheckNode(sample) + active := m.GetActiveAlerts() + want := 1 + if phase == "recovered" { + want = 0 + } + if len(active) != want { + t.Fatalf("phase %s: %d active, want %d", phase, len(active), want) + } + frames[phase] = nodeDiskJSON(t, active) + } + if !bytes.Equal(frames["breach"], frames["missing"]) || !bytes.Equal(frames["breach"], frames["over-capacity"]) { + t.Fatal("unknown changed exported occurrence") + } + if os.Getenv("PULSE_NODE_DISK_FRAMES") == "1" { + fmt.Printf("NODE_DISK_FRAMES=%s\n", nodeDiskJSON(t, frames)) + } +} diff --git a/scripts/tests/test_e2e_test_images.py b/scripts/tests/test_e2e_test_images.py index 8af9ca87f3..96a04d7fb3 100644 --- a/scripts/tests/test_e2e_test_images.py +++ b/scripts/tests/test_e2e_test_images.py @@ -289,7 +289,29 @@ class E2EImageWorkflowTest(unittest.TestCase): self.assertLess(stable, start) self.assertLess(start, probation) end = consumer.index("\n - name:", start) - self.assertIn("!cancelled() && steps.stable.outcome == 'failure'", consumer[start:end]) + receipt_block = consumer[start:end] + self.assertIn("!cancelled()", receipt_block) + self.assertIn("steps.stable.outcome == 'failure'", receipt_block) + if receipt == "Upload Playwright report": + self.assertIn("hashFiles(", receipt_block) + self.assertIn("tests/integration/test-results/**/trace.zip", receipt_block) + self.assertIn("tests/integration/test-results/**/test-failed-*.png", receipt_block) + else: + self.assertIn("steps.stable.outcome == 'success'", receipt_block) + + for receipt in ("Upload probation Playwright report", "Upload probation JUnit results"): + start = consumer.index(f"- name: {receipt}\n") + self.assertLess(probation, start) + end = consumer.find("\n - name:", start) + receipt_block = consumer[start:end if end >= 0 else None] + self.assertIn("!cancelled()", receipt_block) + self.assertIn("steps.probation.outcome == 'failure'", receipt_block) + if receipt == "Upload probation Playwright report": + self.assertIn("hashFiles(", receipt_block) + self.assertIn("tests/integration/test-results-probation/**/trace.zip", receipt_block) + self.assertIn("tests/integration/test-results-probation/**/test-failed-*.png", receipt_block) + else: + self.assertIn("steps.probation.outcome == 'success'", receipt_block) def test_fixture_admission_cannot_skip_source_images_or_lose_completed_receipts(self): self.assert_fixture_admission(WORKFLOW.read_text()) @@ -301,7 +323,11 @@ class E2EImageWorkflowTest(unittest.TestCase): "run: npx playwright test --config=fixture-readiness.config.ts || true"), (" id: fixture\n", " id: fixture\n continue-on-error: true\n"), ('PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true"', 'PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "false"'), - ("!cancelled() && steps.stable.outcome == 'failure'", "failure()"), + ("!cancelled() && (steps.stable.outcome == 'failure'", "failure() && (steps.stable.outcome == 'failure'"), + ("test-results/**/trace.zip", "other-results/**/trace.zip"), + ("test-results-probation/**/trace.zip", "other-results-probation/**/trace.zip"), + ("steps.stable.outcome == 'success'", "steps.stable.outcome == 'skipped'"), + ("steps.probation.outcome == 'success'", "steps.probation.outcome == 'skipped'"), ): with self.subTest(mutation=new): with self.assertRaises(AssertionError): diff --git a/tests/integration/tests/01-core-e2e.spec.ts b/tests/integration/tests/01-core-e2e.spec.ts index 664fe388d8..866e80bcd7 100644 --- a/tests/integration/tests/01-core-e2e.spec.ts +++ b/tests/integration/tests/01-core-e2e.spec.ts @@ -143,15 +143,24 @@ test.describe.serial('Core E2E flows', () => { const cpuInput = editorContainer.locator('input[type="number"]').first(); await expect(cpuInput).toBeVisible(); await cpuInput.fill(String(overrideValue)); - await cpuInput.blur(); + await expect(cpuInput).toHaveValue(String(overrideValue)); + const unsaved = page.getByText('You have unsaved changes'); + // Tabbing to a sibling control keeps the draft inside the editor. + await cpuInput.press('Tab'); + expect(await editorContainer.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await expect(unsaved).toHaveCount(0); + // Desktop commits when focus leaves the whole row. Cards retain the + // draft until Save. Move focus outside the editor, including its slider, + // and wait for the active layout's actual commit boundary. + await page.getByPlaceholder('Search resources...').focus(); const commitMobileEdit = page.getByRole('button', { name: `Save threshold edits for ${resourceName}`, }); + await expect(unsaved.or(commitMobileEdit).first()).toBeVisible(); if (await commitMobileEdit.isVisible().catch(() => false)) { await commitMobileEdit.click(); } - const unsaved = page.getByText('You have unsaved changes'); await expect(unsaved).toBeVisible(); await page.getByRole('button', { name: 'Save Changes' }).click(); await expect(unsaved).not.toBeVisible(); diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 3b8aa65660..2a2f3a837d 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -3,6 +3,7 @@ import { ensureAuthenticated, setMockMode, waitForDefaultMockRuntimeReady, + waitForAuthenticatedAppShell, } from "./helpers"; const getViewportWidth = async ( @@ -237,7 +238,9 @@ test.describe("Mobile viewport flows", () => { test("Platform filter bar does not overflow horizontally", async ({ page, }) => { - await page.goto("/docker/overview"); + await page.goto("/docker/overview", { waitUntil: "domcontentloaded" }); + await waitForAuthenticatedAppShell(page); + await expect(page).toHaveURL(/\/docker\/overview$/); await expect(page.getByPlaceholder("Search containers")).toBeVisible(); diff --git a/tests/integration/tests/11-first-session.spec.ts b/tests/integration/tests/11-first-session.spec.ts index dbec0c6b11..265612eeba 100644 --- a/tests/integration/tests/11-first-session.spec.ts +++ b/tests/integration/tests/11-first-session.spec.ts @@ -8,6 +8,7 @@ import { primaryNavigationLink, trackBrowserRequests, waitForPulseReady, + waitForAuthenticatedAppShell, } from "./helpers"; /** @@ -330,6 +331,7 @@ test.describe.serial("First-session experience", () => { if (!hasFeature) { await page.goto(route, { waitUntil: "domcontentloaded" }); + await waitForAuthenticatedAppShell(page); await page.waitForURL(expectedURL, { timeout: 10_000 }); await expect(page.locator("#root")).toBeVisible(); @@ -352,6 +354,7 @@ test.describe.serial("First-session experience", () => { ).toBe(false); } else { await page.goto(route, { waitUntil: "domcontentloaded" }); + await waitForAuthenticatedAppShell(page); await page.waitForURL(expectedURL, { timeout: 10_000 }); await expect(page.locator("#root")).toBeVisible(); diff --git a/tests/integration/tests/62-runtime-home-onboarding-contract.spec.ts b/tests/integration/tests/62-runtime-home-onboarding-contract.spec.ts index 876ae6b549..9067b6c271 100644 --- a/tests/integration/tests/62-runtime-home-onboarding-contract.spec.ts +++ b/tests/integration/tests/62-runtime-home-onboarding-contract.spec.ts @@ -118,41 +118,37 @@ test.describe("runtime-home onboarding contract", () => { const { token, record } = await created.json() as { token: string; record: { id: string } }; expect(token).toBeTruthy(); expect(record.id).toBeTruthy(); - let tokenNavigationStarted = false; - const onNavigation = () => { - if (new URL(page.url()).searchParams.has("token")) tokenNavigationStarted = true; - }; + // This page owns only the bootstrap being tested. Replacing the setup + // page would also intercept its departing document's in-flight reads. + const bootstrapPage = await page.context().newPage(); let releaseBootstrap!: () => void; const bootstrapHold = new Promise(resolve => { releaseBootstrap = resolve; }); let notifyBlocked!: () => void; const blocked = new Promise(resolve => { notifyBlocked = resolve; }); const holdState = async (route: import("@playwright/test").Route) => { - if (tokenNavigationStarted) { - notifyBlocked(); - await bootstrapHold; - } + notifyBlocked(); + await bootstrapHold; await route.continue(); }; let authentication: Promise | undefined; try { - page.on("framenavigated", onNavigation); - await page.route("**/api/state/summary", holdState); + await bootstrapPage.route("**/api/state/summary", holdState); let completed = false; - authentication = authenticateWithAPIToken(page, token).then(result => { completed = true; return result; }); + authentication = authenticateWithAPIToken(bootstrapPage, token).then(result => { completed = true; return result; }); await Promise.race([blocked, authentication]); expect(completed, "token authentication must await protected browser bootstrap").toBe(false); releaseBootstrap(); expect(await authentication).toBe(true); - await page.goto("/settings/infrastructure?add=agent", { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL(/\/settings\/infrastructure\?add=agent$/); - await expect(page.getByRole("dialog").getByRole("heading", { + await bootstrapPage.goto("/settings/infrastructure?add=agent", { waitUntil: "domcontentloaded" }); + await expect(bootstrapPage).toHaveURL(/\/settings\/infrastructure\?add=agent$/); + await expect(bootstrapPage.getByRole("dialog").getByRole("heading", { name: "Add Pulse Agent", exact: true, })).toBeVisible(); } finally { releaseBootstrap(); await authentication?.catch(() => {}); - await page.unroute("**/api/state/summary", holdState); - page.off("framenavigated", onNavigation); + await bootstrapPage.unroute("**/api/state/summary", holdState); + await bootstrapPage.close(); const revoked = await apiRequest(page, `/api/security/tokens/${encodeURIComponent(record.id)}`, { method: "DELETE", }); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 9586e3acb0..b5bc0be2db 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -163,7 +163,7 @@ export async function waitForAppShell(page: Page, timeoutMs = 20_000) { ); } -async function waitForAuthenticatedAppShell(page: Page, timeoutMs = 20_000) { +export async function waitForAuthenticatedAppShell(page: Page, timeoutMs = 20_000) { // A protected URL and a nonempty root also describe pending bootstrap and // Login after a rejected summary request. The app-scroll-shell mounts only // after loading, needsAuth and enhanced-store admission have completed.