fix(e2e): synchronize editor commits and retain retry diagnostics

Judge consuming panels after their protected document has mounted, and
give the delayed-token regression its own bootstrap page. Leave the whole
Alerts editor before checking for a staged change, including its desktop
focus and card Save contracts. Keep the existing panel timeouts.

Preserve failed-attempt HTML diagnostics when a first retry succeeds and
retain small JUnit results for every completed tier. Upload stable evidence
before probation starts. Integrate the published node disk observation
repair before qualification.
This commit is contained in:
rcourtman
2026-10-25 00:33:48 +01:00
12 changed files with 374 additions and 30 deletions
+7 -6
View File
@@ -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 }}
@@ -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
@@ -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
+2
View File
@@ -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
},
}
+7 -2
View File
@@ -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.
@@ -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))
}
}
+28 -2
View File
@@ -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):
+11 -2
View File
@@ -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();
+4 -1
View File
@@ -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();
@@ -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();
@@ -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<void>(resolve => { releaseBootstrap = resolve; });
let notifyBlocked!: () => void;
const blocked = new Promise<void>(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<boolean> | 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",
});
+1 -1
View File
@@ -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.