feat(platform): show which startup step is running, with progress and ETA (#3813)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
setAgentRuntimeAvailability,
|
||||
stubAgentRuntimeRestart,
|
||||
} from "../../helpers/agent-runtime-availability";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
|
||||
const PROGRESS_BODY = {
|
||||
status: "starting",
|
||||
startup: {
|
||||
phase: "applying_migrations",
|
||||
boot: 1,
|
||||
seq: 1,
|
||||
elapsed_ms: 12_000,
|
||||
phase_elapsed_ms: 12_000,
|
||||
step: {
|
||||
id: "stores.services",
|
||||
label_key: "startup.step.stores_services",
|
||||
measure: "counted",
|
||||
unit: "messages",
|
||||
elapsed_ms: 12_000,
|
||||
done: 400,
|
||||
total: 1000,
|
||||
eta_ms: 30_000,
|
||||
stalled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("renders startup progress in the mobile restart dialog", async ({ testPage }) => {
|
||||
await stubAgentRuntimeRestart(testPage);
|
||||
await testPage.route("**/ready", (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(PROGRESS_BODY),
|
||||
}),
|
||||
);
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
|
||||
await setAgentRuntimeAvailability(testPage, {
|
||||
status: "unavailable",
|
||||
reason: "agentctl_exited",
|
||||
occurred_at: "2026-09-18T00:00:00Z",
|
||||
});
|
||||
const alert = testPage.getByTestId("agent-runtime-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await alert.getByRole("button", { name: "Restart Kandev" }).tap();
|
||||
|
||||
const dialog = testPage.getByTestId("restart-progress-dialog");
|
||||
await expect(dialog).toHaveAttribute("data-phase", "restarting");
|
||||
await expect(dialog).toContainText("Service stores");
|
||||
await expect(dialog).toContainText("400 of 1000 messages");
|
||||
await expect(dialog.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile startup progress dialog");
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
setAgentRuntimeAvailability,
|
||||
stubAgentRuntimeRestart,
|
||||
} from "../../helpers/agent-runtime-availability";
|
||||
|
||||
type ReadyMode = "progress" | "unreachable" | "ready";
|
||||
|
||||
const PROGRESS_BODY = {
|
||||
service: "kandev",
|
||||
version: "e2e-test",
|
||||
status: "starting",
|
||||
startup: {
|
||||
phase: "applying_migrations",
|
||||
boot: 1,
|
||||
seq: 1,
|
||||
elapsed_ms: 12_000,
|
||||
phase_elapsed_ms: 12_000,
|
||||
step: {
|
||||
id: "stores.services",
|
||||
label_key: "startup.step.stores_services",
|
||||
measure: "counted",
|
||||
unit: "messages",
|
||||
elapsed_ms: 12_000,
|
||||
done: 400,
|
||||
total: 1000,
|
||||
rate_per_second: 33.3,
|
||||
eta_ms: 30_000,
|
||||
since_advance_ms: 500,
|
||||
stalled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const UNREACHABLE_BODY = { error: "internal error" };
|
||||
|
||||
const READY_BODY = { service: "kandev", version: "e2e-test", status: "ok" };
|
||||
|
||||
const STALLED_BODY = {
|
||||
service: "kandev",
|
||||
version: "e2e-test",
|
||||
status: "starting",
|
||||
startup: {
|
||||
phase: "applying_migrations",
|
||||
boot: 1,
|
||||
seq: 1,
|
||||
elapsed_ms: 130_000,
|
||||
phase_elapsed_ms: 130_000,
|
||||
step: {
|
||||
id: "stores.services",
|
||||
label_key: "startup.step.stores_services",
|
||||
measure: "counted",
|
||||
unit: "messages",
|
||||
elapsed_ms: 130_000,
|
||||
done: 400,
|
||||
total: 1000,
|
||||
since_advance_ms: 125_000,
|
||||
stalled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives RestartProgressDialog through a real (fully stubbed) restart and
|
||||
* asserts the step-level detail added on top of the base phase/elapsed
|
||||
* rendering (AC-PLATFORM-STARTUP-PROGRESS-003): step label, counted
|
||||
* progress text, ETA, and the AC-PLATFORM-STARTUP-PROGRESS-002.6 status-code
|
||||
* fallback rule for a response with no parseable snapshot - a failing status
|
||||
* falls back to last-known, a successful one does not.
|
||||
*/
|
||||
test.describe("Restart progress dialog startup detail", () => {
|
||||
test("renders step label and progress, and resolves a snapshot-free response by status", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
let readyMode: ReadyMode = "progress";
|
||||
await stubAgentRuntimeRestart(testPage);
|
||||
await testPage.route("**/ready", (route) => {
|
||||
if (readyMode === "progress") {
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(PROGRESS_BODY),
|
||||
});
|
||||
}
|
||||
if (readyMode === "unreachable") {
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(UNREACHABLE_BODY),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(READY_BODY),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
|
||||
await setAgentRuntimeAvailability(testPage, {
|
||||
status: "unavailable",
|
||||
reason: "agentctl_exited",
|
||||
occurred_at: "2026-09-18T00:00:00Z",
|
||||
});
|
||||
const alert = testPage.getByTestId("agent-runtime-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await alert.getByRole("button", { name: "Restart Kandev" }).click();
|
||||
|
||||
const dialog = testPage.getByTestId("restart-progress-dialog");
|
||||
await expect(dialog).toHaveAttribute("data-phase", "restarting");
|
||||
|
||||
await expect(dialog).toContainText("Applying migrations");
|
||||
await expect(dialog).toContainText("Service stores");
|
||||
await expect(dialog).toContainText("400 of 1000 messages");
|
||||
await expect(dialog).toContainText("About 30 seconds remaining");
|
||||
await expect(dialog.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
await expect(dialog.getByText("Last known")).toHaveCount(0);
|
||||
|
||||
// A failing status with no parseable snapshot falls back to last-known
|
||||
// (AC-PLATFORM-STARTUP-PROGRESS-002.6/003.13): the prior step detail
|
||||
// stays on screen, labelled stale rather than cleared.
|
||||
readyMode = "unreachable";
|
||||
|
||||
await expect(dialog.getByText("Last known")).toBeVisible();
|
||||
await expect(dialog).toContainText("Service stores");
|
||||
await expect(dialog).toContainText("400 of 1000 messages");
|
||||
|
||||
// A successful status with no parseable snapshot means ready, not stale
|
||||
// (AC-PLATFORM-STARTUP-PROGRESS-002.6): the status code is authoritative,
|
||||
// so "Last known" must clear even though this response has no snapshot
|
||||
// either.
|
||||
readyMode = "ready";
|
||||
|
||||
await expect(dialog.getByText("Last known")).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Covers AC-PLATFORM-STARTUP-PROGRESS-004.6: the Settings restart dialog
|
||||
* must distinguish a stalled step from a running one, state how long it has
|
||||
* gone without progress, and clear that state within one refresh interval
|
||||
* once the step advances again.
|
||||
*/
|
||||
test("shows a stalled step and clears it once progress resumes", async ({ testPage }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
let stalled = true;
|
||||
await stubAgentRuntimeRestart(testPage);
|
||||
await testPage.route("**/ready", (route) => {
|
||||
const body = stalled ? STALLED_BODY : PROGRESS_BODY;
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
|
||||
await setAgentRuntimeAvailability(testPage, {
|
||||
status: "unavailable",
|
||||
reason: "agentctl_exited",
|
||||
occurred_at: "2026-09-18T00:00:00Z",
|
||||
});
|
||||
const alert = testPage.getByTestId("agent-runtime-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await alert.getByRole("button", { name: "Restart Kandev" }).click();
|
||||
|
||||
const dialog = testPage.getByTestId("restart-progress-dialog");
|
||||
await expect(dialog).toHaveAttribute("data-phase", "restarting");
|
||||
|
||||
await expect(dialog).toContainText("400 of 1000 messages");
|
||||
await expect(dialog).toContainText("Stalled for 3 minutes");
|
||||
|
||||
stalled = false;
|
||||
|
||||
await expect(dialog).not.toContainText("Stalled for 3 minutes");
|
||||
await expect(dialog).toContainText("400 of 1000 messages");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Route } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
|
||||
type StartupSnapshot = Record<string, unknown>;
|
||||
|
||||
const STARTING_SNAPSHOT: StartupSnapshot = {
|
||||
phase: "applying_migrations",
|
||||
boot: 1,
|
||||
seq: 1,
|
||||
elapsed_ms: 12_000,
|
||||
phase_elapsed_ms: 12_000,
|
||||
step: {
|
||||
id: "stores.services",
|
||||
label_key: "startup.step.stores_services",
|
||||
measure: "counted",
|
||||
unit: "messages",
|
||||
elapsed_ms: 12_000,
|
||||
done: 400,
|
||||
total: 1000,
|
||||
eta_ms: 30_000,
|
||||
stalled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_BODY = {
|
||||
status: "starting",
|
||||
startup: {
|
||||
...STARTING_SNAPSHOT,
|
||||
seq: 2,
|
||||
step: { ...(STARTING_SNAPSHOT.step as Record<string, unknown>), done: 700, eta_ms: 10_000 },
|
||||
},
|
||||
};
|
||||
|
||||
const READY_BODY = {
|
||||
status: "ok",
|
||||
startup: { phase: "ready", boot: 1, seq: 3, elapsed_ms: 20_000, phase_elapsed_ms: 500 },
|
||||
};
|
||||
|
||||
const POST_RELOAD_HTML =
|
||||
'<!DOCTYPE html><html><body><div data-testid="post-reload-marker">app is up</div></body></html>';
|
||||
|
||||
/**
|
||||
* Drives the Go-rendered startup page (internal/backendapp/startup_page.go)
|
||||
* through a real browser instead of httptest string-matching: the initial
|
||||
* markup comes from the production renderer via the E2E-only fixture route,
|
||||
* and only /ready and the app navigation are mocked, so the embedded poll
|
||||
* script runs unmodified.
|
||||
*/
|
||||
test.describe("Startup page (real render)", () => {
|
||||
test("polls for step progress and reloads once the snapshot reports ready", async ({
|
||||
backend,
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
const startingHtml = await request
|
||||
.post(`${backend.baseUrl}/api/v1/e2e/startup-page-fixture`, { data: STARTING_SNAPSHOT })
|
||||
.then((res) => res.text());
|
||||
|
||||
const context = await browser.newContext({ baseURL: backend.frontendUrl });
|
||||
const page = await context.newPage();
|
||||
|
||||
let readyBody: unknown = { status: "starting", startup: STARTING_SNAPSHOT };
|
||||
let appIsReady = false;
|
||||
|
||||
const fulfillReady = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: appIsReady ? 200 : 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(readyBody),
|
||||
});
|
||||
|
||||
const fulfillAppRoute = (route: Route) =>
|
||||
appIsReady
|
||||
? route.fulfill({ status: 200, contentType: "text/html", body: POST_RELOAD_HTML })
|
||||
: route.fulfill({ status: 503, contentType: "text/html", body: startingHtml });
|
||||
|
||||
await page.route(
|
||||
(url) => url.pathname === "/ready",
|
||||
(route) => fulfillReady(route),
|
||||
);
|
||||
await page.route(
|
||||
(url) => url.pathname === "/startup-page-e2e-fixture",
|
||||
(route) => fulfillAppRoute(route),
|
||||
);
|
||||
|
||||
await page.goto("/startup-page-e2e-fixture");
|
||||
|
||||
await expect(page.locator("#startup-phase")).toHaveText("Applying migrations");
|
||||
await expect(page.locator("#startup-step-label")).toHaveText("Service stores");
|
||||
await expect(page.locator("#startup-progress")).toHaveText("400 of 1000 messages");
|
||||
await expect(page.locator("#startup-eta")).toHaveText("About 30 seconds remaining");
|
||||
|
||||
// A failing response with a malformed snapshot must keep the last valid
|
||||
// detail and mark it as stale. The status code remains authoritative when
|
||||
// the body shape cannot be trusted.
|
||||
readyBody = { status: "starting", startup: { ...STARTING_SNAPSHOT, step: {} } };
|
||||
await expect(page.getByText("Last known")).toBeVisible();
|
||||
await expect(page.locator("#startup-step-label")).toHaveText("Service stores");
|
||||
await expect(page.locator("#startup-progress")).toHaveText("400 of 1000 messages");
|
||||
|
||||
// Poll-driven update: the embedded script re-fetches /ready on its own
|
||||
// schedule and re-renders in place, with no test-issued reload.
|
||||
readyBody = ADVANCED_BODY;
|
||||
await expect(page.locator("#startup-progress")).toHaveText("700 of 1000 messages");
|
||||
await expect(page.locator("#startup-eta")).toHaveText("About 10 seconds remaining");
|
||||
|
||||
// Reload-on-ready: once a poll response's snapshot reports phase "ready",
|
||||
// the script calls location.reload() itself; the re-navigation this test
|
||||
// observes is the real app route being requested a second time.
|
||||
appIsReady = true;
|
||||
readyBody = READY_BODY;
|
||||
await expect(page.getByTestId("post-reload-marker")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user