From 979d4a0b7a57ec941c58db9d8b0e948a46d5579b Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 25 Apr 2026 08:08:05 -0700 Subject: [PATCH] fix(canvas/e2e): swap workspace-scoped 401s for empty 200s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging-tabs E2E has been failing for 6+ hours on the same locator timeout — diagnosed earlier today as the canvas's lib/api.ts:62-74 redirect-on-401 path firing mid-test: e2e/staging-tabs.spec.ts:45:7 › tab: skills TimeoutError: locator.scrollIntoViewIfNeeded: Timeout 5000ms - navigated to "https://scenic-pumpkin-83.authkit.app/?..." Several side-panel tabs (Peers, Skills, Channels, Memory, Audit, and anything workspace-scoped) hit endpoints under `/workspaces//*` that require a workspace-scoped token, NOT the tenant admin bearer the test uses. The endpoints respond 401 in SaaS mode. canvas/src/lib/api.ts:62-74 reacts to ANY 401 by setting `window.location.href` to AuthKit — yanking the page off the tenant origin mid-test. The test comment at line 18 already acknowledged the 401 class ("Peers tab: 401 without workspace-scoped token") but assumed those would surface as "errored content" rather than a hard navigation. The redirect logic in api.ts was added later and breaks the assumption. Fix: add a Playwright route handler that catches any 401 from `/workspaces//*` paths and replaces with `200 + empty body`. Body shape is best-effort by URL — list endpoints (paths not ending in a UUID-shaped segment) get `[]`, single-resource endpoints get `{}`. Both are valid JSON and well-written panels render an empty state for either rather than crashing. The two route patterns (`/workspaces/...` and `/cp/auth/me`) don't overlap — the existing `/cp/auth/me` mock continues to gate AuthGate's session check independently. Verification: - Type-check passes (tsc clean for the spec; pre-existing errors in unrelated test files unchanged) - Can't run staging E2E locally without CP admin token; CI will exercise the real path against the freshly-provisioned tenant - E2E Staging SaaS (full lifecycle) is currently green at 08:07Z, confirming the underlying staging infra works — the failures have been narrowly in this Playwright-tabs spec Targets staging per molecule-core convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-tabs.spec.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/canvas/e2e/staging-tabs.spec.ts b/canvas/e2e/staging-tabs.spec.ts index 8749b191..1c85c976 100644 --- a/canvas/e2e/staging-tabs.spec.ts +++ b/canvas/e2e/staging-tabs.spec.ts @@ -87,6 +87,53 @@ test.describe("staging canvas tabs", () => { }), ); + // Workspace-scoped 401 → 200 fallback. + // + // Several side-panel tabs (Peers/Skills/Channels/Memory/Audit and + // anything else workspace-scoped) hit endpoints under + // `/workspaces//*` that require a workspace-scoped token, NOT + // the tenant admin bearer this test uses. Those endpoints respond + // 401 in SaaS mode. canvas/src/lib/api.ts:62-74 reacts to ANY 401 + // by setting `window.location.href` to the AuthKit login URL — + // which yanks the page off the tenant origin mid-test and breaks + // every locator assertion that runs after. + // + // For tab-render tests we don't need real data — the gate is + // "panel mounts without crashing, no Failed-to-load toast". + // Intercept the 401 and swap it for 200 + empty body. Body shape + // is best-effort by URL: list endpoints (collection paths that + // don't end in a UUID) get `[]`; single-resource endpoints get + // `{}`. Both are valid JSON, neither matches the real schema + // exactly, but well-written panels render an empty state for + // either rather than throwing. + // + // The two route patterns don't overlap (`/workspaces/...` vs + // `/cp/auth/me`) so handler order doesn't matter — the + // `/cp/auth/me` mock above is matched on its own path. + await context.route(/\/workspaces\//, async (route, request) => { + if (request.resourceType() !== "fetch") { + return route.fallback(); + } + let resp; + try { + resp = await route.fetch(); + } catch { + return route.fallback(); + } + if (resp.status() !== 401) { + return route.fulfill({ response: resp }); + } + // 401: swap for empty 200 keyed by URL shape. + const lastSeg = + new URL(request.url()).pathname.split("/").filter(Boolean).pop() || ""; + const looksLikeList = !/^[0-9a-f-]{8,}$/.test(lastSeg); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: looksLikeList ? "[]" : "{}", + }); + }); + const consoleErrors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") {