From 754f361c03770666f423526f6b74e7fb7c8aef79 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 17:32:12 -0700 Subject: [PATCH 1/8] =?UTF-8?q?fix(e2e):=20poll=20instance=5Fstatus=20not?= =?UTF-8?q?=20status=20=E2=80=94=20waitFor=20never=20matched,=20masked=20r?= =?UTF-8?q?eal=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging Canvas Playwright E2E has been timing out at 1200s on every recent run. Found via /code-review-and-quality on the staging→main promotion chain. The CP /cp/admin/orgs response shape is (handlers/admin.go:118): type adminOrgSummary struct { ... InstanceStatus string `json:"instance_status,omitempty"` ... } There is NO top-level `status` field. The waitFor predicate compared `row.status === "running"` against undefined on every poll — the predicate could never resolve truthy. The harness invariably wedged on the 20-min timeout regardless of whether the tenant was actually provisioned. This bug has been double-edged: - It MASKED the #242 pq-cache-collision class for hours: the tenants WERE provisioning fine, but the test couldn't tell. - It survived #255, #257 (real CP fixes) — the test still timed out, making us suspect more CP bugs that didn't exist. Fix: poll `row.instance_status` instead. One-line change. Identical fix for the failed-state branch one line below. No new tests for the harness itself; the fix's correctness is verified by the next E2E run on the affected branch passing end-to-end. If it doesn't pass after this, there's a separate bug we can hunt cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-setup.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index 7147f4ea..d8e77521 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -105,15 +105,24 @@ export default async function globalSetup(_config: FullConfig): Promise { } console.log(`[staging-setup] Org created: ${slug}`); - // 2. Wait for tenant running (admin-orgs list is the status source) + // 2. Wait for tenant running (admin-orgs list is the status source). + // + // The CP /cp/admin/orgs endpoint returns each org with an + // `instance_status` field (handlers/admin.go:adminOrgSummary, + // sourced from `org_instances.status`). NOT `status` — there's no + // top-level `status` on the row at all. A previous version of this + // test polled `row.status`, which was always undefined, so this + // waitFor never resolved truthy and the harness invariably timed + // out at 1200s — masking real CP bugs (see #242 chain) AND + // surviving real CP fixes alike. await waitFor( async () => { const r = await jsonFetch(`${CP_URL}/cp/admin/orgs`, { headers: adminAuth }); if (r.status !== 200) return null; const row = (r.body?.orgs || []).find((o: any) => o.slug === slug); if (!row) return null; - if (row.status === "running") return true; - if (row.status === "failed") throw new Error(`provision failed: ${slug}`); + if (row.instance_status === "running") return true; + if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`); return null; }, PROVISION_TIMEOUT_MS, From edcac16b81fb3539c44f2828589c83876cce47d5 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 17:45:48 -0700 Subject: [PATCH 2/8] =?UTF-8?q?fix(e2e):=20use=20staging.moleculesai.app?= =?UTF-8?q?=20for=20tenant=20DNS=20=E2=80=94=20wrong=20zone=20hung=20TLS?= =?UTF-8?q?=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second related E2E bug, surfaced after #2066's instance_status fix let the harness reach the TLS readiness step: Error: tenant TLS: timed out after 180s The CP provisioner writes staging tenant DNS as .staging.moleculesai.app (with the staging. subdomain prefix — visible in the EC2 provisioner DNS log line). The harness was building https://.moleculesai.app (prod-zone shape), so DNS literally didn't resolve, fetch threw NXDOMAIN inside the silent catch, and waitFor saw null on every 5s poll until 180s elapsed. Fix: parameterize as STAGING_TENANT_DOMAIN env var, default staging.moleculesai.app. Doc-comment example updated to match. Override hatch is there only for ops running this harness against a non-default zone. Verified manually: a freshly-provisioned tenant (e2e-canvas-20260425-sav9fe) was unreachable at the prod-shaped URL (NXDOMAIN) but reached CF at the staging-shaped URL. teardown.ts only hits CP, not the tenant URL — no fix needed there. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-setup.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index d8e77521..b76e395f 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -5,7 +5,7 @@ * the per-tenant admin token, provisions one hermes workspace, waits * for online, then exports: * - * STAGING_TENANT_URL https://.moleculesai.app + * STAGING_TENANT_URL https://.staging.moleculesai.app * STAGING_WORKSPACE_ID UUID of the hermes workspace * STAGING_TENANT_TOKEN per-tenant admin bearer (for spec requests) * STAGING_SLUG org slug (used by teardown) @@ -16,6 +16,11 @@ * CP_ADMIN_API_TOKEN). Drives provision + * tenant-token retrieval + teardown via a * single credential. + * STAGING_TENANT_DOMAIN default: staging.moleculesai.app — the + * DNS suffix the CP provisioner writes for + * staging tenants. Override only when + * running this harness against a non-default + * zone. */ import type { FullConfig } from "@playwright/test"; @@ -25,6 +30,14 @@ import { join } from "path"; const CP_URL = process.env.MOLECULE_CP_URL || "https://staging-api.moleculesai.app"; const ADMIN_TOKEN = process.env.MOLECULE_ADMIN_TOKEN; const STAGING = process.env.CANVAS_E2E_STAGING === "1"; +// Tenant DNS zone for staging. CP provisioner registers DNS as +// `.staging.moleculesai.app` (see internal/provisioner/ec2.go's +// EC2 provisioner: DNS log line). The previous default of plain +// `moleculesai.app` matched prod tenant naming and silently broke +// every staging E2E at the TLS readiness step — DNS literally didn't +// resolve, fetch threw NXDOMAIN, waitFor saw null on every poll, and +// the harness wedged at TLS_TIMEOUT_MS instead of failing loud. +const TENANT_DOMAIN = process.env.STAGING_TENANT_DOMAIN || "staging.moleculesai.app"; // Tenant cold boot on staging regularly takes 12-15 min when the // workspace-server Docker image isn't already cached on the AMI. Raised @@ -142,7 +155,7 @@ export default async function globalSetup(_config: FullConfig): Promise { ); } const tenantToken: string = tokRes.body.admin_token; - const tenantURL = `https://${slug}.moleculesai.app`; + const tenantURL = `https://${slug}.${TENANT_DOMAIN}`; console.log(`[staging-setup] Tenant URL: ${tenantURL}`); // 4. TLS readiness From 4fdeabdbe001d1ddf58f09080e1016d5f850d8a3 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 18:13:13 -0700 Subject: [PATCH 3/8] =?UTF-8?q?fix(e2e):=20send=20X-Molecule-Org-Id=20head?= =?UTF-8?q?er=20=E2=80=94=20TenantGuard=20404s=20without=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third E2E bug in the staging→main chain, found while debugging the \`Workspace create 404\` failure that surfaced after the previous two E2E fixes (instance_status, staging.moleculesai.app DNS). Root cause: workspace-server's \`middleware/TenantGuard\` middleware returns 404 (not 401/403, intentionally — see comment in \`tenant_guard.go\`: "must not be inferable by probing other orgs' machines") when a request to the tenant origin lacks one of: - X-Molecule-Org-Id header matching MOLECULE_ORG_ID env on the tenant - Fly-Replay-Src state from the CP router (production browser path) - Same-origin Canvas (Referer == Host) The E2E was a direct GitHub-Actions curl with neither — every non- allowlisted route 404'd with the platform's ratelimit headers but none of the security headers, which made it look like a missing route in the platform. The org UUID is already on the admin-orgs row alongside instance_status, so capture it during the readiness poll and add it to the tenantAuth header bag. Both /workspaces (POST) and /workspaces/:id (GET) now carry it. Allowlist still contains /health, /metrics, /registry/register, /registry/heartbeat — so the TLS readiness step (which hits /health) keeps working without the header. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-setup.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index b76e395f..963f9ccb 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -128,13 +128,23 @@ export default async function globalSetup(_config: FullConfig): Promise { // waitFor never resolved truthy and the harness invariably timed // out at 1200s — masking real CP bugs (see #242 chain) AND // surviving real CP fixes alike. + // Capture the org UUID alongside the running check — every request + // we send to the tenant URL after this point needs an + // X-Molecule-Org-Id header (see workspace-server middleware/tenant_guard.go). + // Without it, TenantGuard returns 404 ("must not be inferable by + // probing other orgs' machines"). The CP returns the id on the + // admin-orgs row; capture it here while we're already polling. + let orgID = ""; await waitFor( async () => { const r = await jsonFetch(`${CP_URL}/cp/admin/orgs`, { headers: adminAuth }); if (r.status !== 200) return null; const row = (r.body?.orgs || []).find((o: any) => o.slug === slug); if (!row) return null; - if (row.instance_status === "running") return true; + if (row.instance_status === "running") { + orgID = row.id; + return true; + } if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`); return null; }, @@ -142,7 +152,10 @@ export default async function globalSetup(_config: FullConfig): Promise { 15_000, "tenant provision", ); - console.log(`[staging-setup] Tenant running`); + if (!orgID) { + throw new Error(`expected admin-orgs row to carry id, got empty for slug=${slug}`); + } + console.log(`[staging-setup] Tenant running (org_id=${orgID})`); // 3. Fetch per-tenant admin token const tokRes = await jsonFetch( @@ -176,7 +189,17 @@ export default async function globalSetup(_config: FullConfig): Promise { ); // 5. Provision workspace - const tenantAuth = { Authorization: `Bearer ${tenantToken}` }; + // + // tenantAuth carries TWO headers, both required: + // - Authorization: Bearer — wsAdmin middleware gate + // - X-Molecule-Org-Id: — TenantGuard cross-org gate + // Missing the org-id header silently 404s every non-allowlisted + // route, with no body and no security headers. The 404 is intentional + // (existence-non-inference) which makes it look like a missing route. + const tenantAuth = { + "Authorization": `Bearer ${tenantToken}`, + "X-Molecule-Org-Id": orgID, + }; const ws = await jsonFetch(`${tenantURL}/workspaces`, { method: "POST", headers: tenantAuth, From 4e3bb3795a19f61c1f28edc6f21f786dab8cefd8 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 18:38:28 -0700 Subject: [PATCH 4/8] fix(e2e): canvas-hydration wait used a selector that never appears pre-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth E2E bug in the staging→main chain. The previous three (#2066 setup-phase fixes) let the harness reach the actual Playwright spec. This one is in staging-tabs.spec.ts itself. The spec at L78 waits 45s for one of: [role="tablist"], [data-testid="hydration-error"] Both targets are wrong: 1. [role="tablist"] only appears AFTER the workspace node is clicked (which happens 25 lines later at L100). Waiting for it BEFORE the click can never resolve, so the wait always times out at 45s regardless of whether the canvas actually loaded. 2. [data-testid="hydration-error"] doesn't exist anywhere in the canvas. The error banner at app/page.tsx:62 only had role="alert" — which collides with toast notifications and other alert-type elements, so a more-specific selector was never wired. Two-part fix: - Test waits on `[aria-label="Molecule AI workspace canvas"]` instead — that's the React Flow wrapper (Canvas.tsx:150), always present once hydrated regardless of workspace count or selection state. Hydration-error banner remains the secondary OR target for the failure path. - app/page.tsx hydration-error banner gets the missing `data-testid="hydration-error"` attribute. role="alert" stays for accessibility; the testid is for programmatic detection without conflict. After this lands, the staging-tabs spec should advance past the initial wait, click the workspace node, and exercise each tab. If a tab fails, we get a proper test failure rather than a 45s timeout that obscures everything. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-tabs.spec.ts | 11 ++++++++--- canvas/src/app/page.tsx | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/canvas/e2e/staging-tabs.spec.ts b/canvas/e2e/staging-tabs.spec.ts index 412953a5..fa99fa5e 100644 --- a/canvas/e2e/staging-tabs.spec.ts +++ b/canvas/e2e/staging-tabs.spec.ts @@ -73,10 +73,15 @@ test.describe("staging canvas tabs", () => { await page.goto(tenantURL, { waitUntil: "networkidle" }); // Canvas hydration races WebSocket connect + /workspaces fetch. - // Wait for the tablist element (appears after a workspace is - // selected) or the hydration-error banner — whichever wins first. + // Wait for the React Flow canvas wrapper (always present once + // hydrated, even with zero workspaces) or the hydration-error + // banner — whichever wins first. Previous version of this wait + // used `[role="tablist"]`, but that selector only appears AFTER + // a workspace node is clicked (which happens below at L100), so + // the wait would always time out at 45s before any meaningful + // failure surfaced. await page.waitForSelector( - '[role="tablist"], [data-testid="hydration-error"]', + '[aria-label="Molecule AI workspace canvas"], [data-testid="hydration-error"]', { timeout: 45_000 }, ); diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index 74291409..8b79ef83 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -61,6 +61,11 @@ export default function Home() { {hydrationError && (

{hydrationError}

From 59b5449a4e07543e0018881315201c3a0ae29880 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 19:07:32 -0700 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20re-trigger=20CI=20=E2=80=94=20stag?= =?UTF-8?q?ing=20CP=20now=20has=20CP#259=20SetMaxIdleConns(0)=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c2504d9361f4a72b371ff117d51b162ae839326b Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 19:43:46 -0700 Subject: [PATCH 6/8] =?UTF-8?q?fix(e2e):=20page.goto=20waitUntil=20network?= =?UTF-8?q?idle=20never=20settles=20=E2=80=94=20switch=20to=20domcontentlo?= =?UTF-8?q?aded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth E2E bug surfaced by the previous run. After the four setup- phase fixes (instance_status, DNS zone, X-Molecule-Org-Id, hydration selector) plus CP#259 ending the pq cache class, the harness finally reached the actual page navigation step — and timed out there: TimeoutError: page.goto: Timeout 45000ms exceeded. navigating to "https://...staging.moleculesai.app/", waiting until "networkidle" `waitUntil: "networkidle"` waits for 500ms of network silence. The canvas keeps a WebSocket connection open + polls /events and /workspaces every few seconds for status updates, so the network is never idle — page.goto sits on it until the default 45s timeout and throws. Fix: switch to `waitUntil: "domcontentloaded"`. Returns as soon as the HTML is parsed. React hydration plus the existing `waitForSelector` line below is what actually gates ready-for- interaction; the goto's job is just to land on the page. This is a generally-applicable lesson — networkidle is broken for any SPA with a heartbeat. Notably, our existing canvas unit tests that mock @xyflow/react and don't open WebSockets DON'T hit this, which is why this only surfaces against staging. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-tabs.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/canvas/e2e/staging-tabs.spec.ts b/canvas/e2e/staging-tabs.spec.ts index fa99fa5e..6d444d86 100644 --- a/canvas/e2e/staging-tabs.spec.ts +++ b/canvas/e2e/staging-tabs.spec.ts @@ -70,7 +70,13 @@ test.describe("staging canvas tabs", () => { } }); - await page.goto(tenantURL, { waitUntil: "networkidle" }); + // waitUntil="networkidle" is wrong here — the canvas keeps a + // WebSocket open + polls /events and /workspaces every few + // seconds, so the network is *never* idle for 500ms. page.goto + // would hang until its 45s default timeout. "domcontentloaded" + // returns as soon as the HTML is parsed; React hydration + the + // selector wait below is what actually gates ready-for-interaction. + await page.goto(tenantURL, { waitUntil: "domcontentloaded" }); // Canvas hydration races WebSocket connect + /workspaces fetch. // Wait for the React Flow canvas wrapper (always present once From 6c70b413e009748cbce597c7568c1b6b07d76cfb Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 19:59:04 -0700 Subject: [PATCH 7/8] =?UTF-8?q?fix(e2e):=20mock=20/cp/auth/me=20=E2=80=94?= =?UTF-8?q?=20AuthGate=20redirect=20was=20preventing=20canvas=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth E2E bug, surfaced after the page.goto-domcontentloaded fix finally let the navigation complete. The harness now reaches the canvas-root selector wait but still times out because the canvas never renders: TimeoutError: page.waitForSelector: Timeout 45000ms exceeded. waiting for [aria-label="Molecule AI workspace canvas"] Root cause: canvas/src/components/AuthGate.tsx wraps the page, fetches /cp/auth/me on mount, and redirects to the login page when the response is 401. The bearer header we set via context.setExtraHTTPHeaders works for platform API calls but does NOT satisfy /cp/auth/me — that endpoint is cookie-based (WorkOS session). So: 1. AuthGate mounts 2. Calls fetchSession() → /cp/auth/me → 401 (no session cookie) 3. AuthGate transitions to anonymous → redirectToLogin() 4. Browser navigates away from tenant URL 5. The React Flow canvas root with the aria-label never mounts 6. waitForSelector times out at 45s Fix: context.route() intercepts /cp/auth/me and returns a fake Session JSON so AuthGate resolves to "authenticated" and renders its children. The session contents are cosmetic — Session.org_id and Session.user_id appear in a few canvas surfaces but never fail on dummy values. This is the cleanest fix path. Alternatives considered + rejected: - Add a ?e2e=1 backdoor to AuthGate: production code shouldn't have a "skip auth" flag, even gated. - Real WorkOS login flow in Playwright: too much overhead per run. - Skip the canvas UI test, test only API: defeats the point of the staging E2E (which is to catch UI regressions before promotion). After this lands the harness should reach the workspace-node click step and exercise tabs — only then can a real product bug (rather than a test-harness bug) surface. The 6-bug chain mapped to: 1. instance_status field name (#2066) 2. staging.moleculesai.app DNS zone (#2066) 3. X-Molecule-Org-Id TenantGuard header (#2066) 4. Hydration selector waited pre-click (#2066) 5. networkidle never settles (this commit's parent) 6. AuthGate /cp/auth/me redirect (this commit) Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-tabs.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/canvas/e2e/staging-tabs.spec.ts b/canvas/e2e/staging-tabs.spec.ts index 6d444d86..9cd93a4d 100644 --- a/canvas/e2e/staging-tabs.spec.ts +++ b/canvas/e2e/staging-tabs.spec.ts @@ -63,6 +63,30 @@ test.describe("staging canvas tabs", () => { Authorization: `Bearer ${tenantToken}`, }); + // canvas/src/components/AuthGate.tsx fetches /cp/auth/me on mount + // and redirects to the login page on 401. The bearer header above + // is for platform API calls — it does NOT satisfy /cp/auth/me, + // which is cookie-based (WorkOS session). Without this mock, the + // canvas page mounts AuthGate, sees 401 from /cp/auth/me, and + // redirects away from the tenant URL before the React Flow root + // ever renders. The [aria-label] selector wait then times out. + // + // Intercept /cp/auth/me + return a fake Session shape so AuthGate + // resolves to "authenticated" and renders {children}. The session + // contents are cosmetic — the canvas only inspects org_id/user_id + // in a few places that don't fail when these are dummy values. + await context.route("**/cp/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + user_id: `e2e-test-user-${workspaceId}`, + org_id: "e2e-test-org", + email: "e2e@test.local", + }), + }), + ); + const consoleErrors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") { From e58ecf2974722e55fc74c81b7dea81da14f213e6 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 20:37:36 -0700 Subject: [PATCH 8/8] =?UTF-8?q?fix(e2e):=20scrollIntoView=20before=20toBeV?= =?UTF-8?q?isible=20=E2=80=94=20clipped=20tabs=20were=20"missing"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh E2E bug, surfaced after the AuthGate mock from the previous commit finally let the harness reach the tab-iteration loop: Error: tab-skills button missing — TABS list may have drifted Locator: locator('#tab-skills') The TABS bar in SidePanel is `overflow-x-auto` (intentional — there are 13 tabs and they don't all fit on smaller viewports; the right-edge fade gradient signals the overflow). Tabs after position ~3 are clipped, and Playwright's `toBeVisible()` returns false for clipped elements (it checks getBoundingClientRect against viewport). Fix: `scrollIntoViewIfNeeded()` before the visibility assertion, mirroring what SidePanel's own keyboard handler does on arrow-key navigation. The tab is then in view and `toBeVisible()` passes. This was the test's 7th and (probably) final harness bug. The chain mapping all the way from "staging E2E timed out at 1200s" this morning: 1. instance_status field name (#2066) 2. staging.moleculesai.app DNS zone (#2066) 3. X-Molecule-Org-Id TenantGuard header (#2066) 4. Hydration selector waited pre-click (#2066) 5. networkidle never settles (this PR's parent commits) 6. AuthGate /cp/auth/me redirect 7. Tab buttons clipped by overflow-x-auto If THIS run still fails, the failure surfaces in actual product behavior (a tab's panel content), not test mechanics. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/e2e/staging-tabs.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/canvas/e2e/staging-tabs.spec.ts b/canvas/e2e/staging-tabs.spec.ts index 9cd93a4d..8749b191 100644 --- a/canvas/e2e/staging-tabs.spec.ts +++ b/canvas/e2e/staging-tabs.spec.ts @@ -141,6 +141,15 @@ test.describe("staging canvas tabs", () => { for (const tabId of TAB_IDS) { await test.step(`tab: ${tabId}`, async () => { const tabButton = page.locator(`#tab-${tabId}`); + // The TABS bar is `overflow-x-auto` (SidePanel.tsx:~tabs + // wrapper) — tabs after position ~3 are clipped behind the + // right-edge fade gradient on smaller viewports. Playwright's + // `toBeVisible()` returns false for clipped elements, so a + // bare visibility check fails on `skills` and later tabs in + // CI. scrollIntoViewIfNeeded brings the button into view + // before the visibility check, mirroring what SidePanel's own + // keyboard handler does on arrow-key navigation. + await tabButton.scrollIntoViewIfNeeded({ timeout: 5_000 }); await expect( tabButton, `tab-${tabId} button missing — TABS list may have drifted`,