Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render

One conflict, scripts/smoke-web-app.mjs: #2056 added a second phase to the
smoke (the same app declaring _meta.ui.domain, asserting it renders from the
dedicated app origin rather than an opaque one) while this branch extracted
the smoke's flow into scripts/lib/mcp-app-flow.mjs.

Resolved by keeping both: phase 2 now runs through the shared helper too,
which grows three knobs it needs and pack:verify does not —

  - expectDeepLink: phase 2 re-navigates the SAME page, so the token gate is
    already proven and a rejected re-navigate would fail at the connect wait
    regardless;
  - what: so a phase-2 ready timeout doesn't read as a phase-1 one;
  - extraDiagnostics: phase 2 appends the frame list, which is what separates
    "never rendered" from "rendered at the wrong origin".

Each is covered by a new unit test, since neither consumer exercises a
failure branch.

pack:verify deliberately drives phase 1 only. The dedicated origin is served
by the same runner the packaging assertions already cover and reads nothing
new from disk (app-origin-controller.ts has no file reads), so a second
browser launch there would buy no packaging signal.

Verified on the merged tree: smoke:web:app passes both phases (dedicated
origin http://127.0.0.1:6278/app-document/<id>), and pack:verify passes end
to end against the real tarball.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 09:05:53 -04:00
commit df938777b1
2 changed files with 2557 additions and 76 deletions

View file

@ -225,6 +225,18 @@ export async function loadChromium(repoRoot) {
* Throws with the last observed `data-app-status` / `data-app-error` rather than
* a bare timeout, so a failure names its stage.
*
* Three knobs exist for `smoke:web:app`'s second phase (#2056), which
* re-navigates the *same* page to a second server declaring `_meta.ui.domain`:
*
* - `expectDeepLink` — the token gate is only worth asserting on the first
* navigate. A re-navigate that were rejected fails at the connect wait
* anyway, and phase 2's real subject is the origin, not the gate.
* - `what` — the noun in the ready-failure message, so a phase-2 timeout does
* not read as a phase-1 one.
* - `extraDiagnostics` — appended to that message. Phase 2 lists the page's
* frames, which is what distinguishes "the app never rendered" from "it
* rendered at the wrong origin".
*
* Takes only the small slice of Playwright's `page` it uses (`goto`, `locator`
* → `waitFor`/`getAttribute`/`count`), which is what lets its failure paths be
* unit-tested against a stand-in — a smoke only ever exercises its happy path.
@ -232,6 +244,9 @@ export async function loadChromium(repoRoot) {
export async function driveAppFlow({
page,
url,
expectDeepLink = true,
what = "app",
extraDiagnostics = null,
gotoTimeoutMs = 30_000,
connectTimeoutMs = 45_000,
readyTimeoutMs = 45_000,
@ -247,13 +262,15 @@ export async function driveAppFlow({
}
// 1. The deep link must be accepted (not rejected by the token gate).
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: gotoTimeoutMs });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
if (expectDeepLink) {
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: gotoTimeoutMs });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
}
}
// 2. Connected to the test server.
@ -273,10 +290,11 @@ export async function driveAppFlow({
? await form.getAttribute("data-app-status")
: "(no apps-form)";
const appError = present ? await form.getAttribute("data-app-error") : null;
const extra = extraDiagnostics ? await extraDiagnostics() : "";
throw new Error(
`app never reached data-app-status="ready" (last: "${appStatus}"` +
`${what} never reached data-app-status="ready" (last: "${appStatus}"` +
`${appError ? `, data-app-error="${appError}"` : ""}) — the sandbox proxy ` +
`or the UI-protocol bridge failed to complete`,
`or the UI-protocol bridge failed to complete${extra ? ` — ${extra}` : ""}`,
);
}
}

View file

@ -154,6 +154,42 @@ describe("driveAppFlow", () => {
);
});
it("skips the deep-link gate when the caller re-navigates an open page", async () => {
// smoke:web:app phase 2 (#2056) re-navigates the SAME page to a second
// server. The gate was proven on the first navigate, and a rejected
// re-navigate fails at the connect wait anyway — so a stale/absent
// data-deeplink must not fail the phase.
const plan = happyPlan();
plan[STATUS] = { attrs: {} };
await driveAppFlow({
page: fakePage(plan),
url: "http://x/",
expectDeepLink: false,
});
});
it("names the phase and appends its diagnostics on a ready timeout", async () => {
// A phase-2 timeout that read as a phase-1 one would send the reader to the
// wrong half of the smoke; the frame list is what separates "never
// rendered" from "rendered at the wrong origin".
const plan = happyPlan();
plan[READY] = { waitFails: true };
plan[FORM] = { count: 1, attrs: { "data-app-status": "loading" } };
await assert.rejects(
driveAppFlow({
page: fakePage(plan),
url: "http://x/",
what: "dedicated-origin app",
extraDiagnostics: () => "frames: a, b",
}),
(err) => {
assert.match(err.message, /^dedicated-origin app never reached/);
assert.match(err.message, /frames: a, b$/);
return true;
},
);
});
it("says so when the apps form never mounted at all", async () => {
const plan = happyPlan();
plan[READY] = { waitFails: true };