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:
Henry
2026-09-20 07:06:19 +08:00
committed by GitHub
parent 1727435c14
commit 8779c88d79
124 changed files with 7527 additions and 347 deletions
@@ -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();
});
});