diff --git a/e2e/gate-modal-behavior.spec.js b/e2e/gate-modal-behavior.spec.js new file mode 100644 index 0000000..cb98d6a --- /dev/null +++ b/e2e/gate-modal-behavior.spec.js @@ -0,0 +1,118 @@ +import { test, expect } from "@playwright/test"; +import { applyDefaultRoutes, guestIdentity } from "./fixtures/apiFixtures.js"; + +/** + * Gate fixes for the PR #100 findings on the shared modal shell + * (src/sharedControls.js): + * + * F1 — a repeated open of the already active modal must preserve the page's + * pre-modal overflow and background-inert state. The walkthrough's blur + * and delayed Tab handlers re-open that same modal, so an open while it + * is already active used to save "hidden" as the previous overflow and + * overwrite the saved background state — closing then left the page + * unable to scroll with an inert, aria-hidden background. + * + * F2 — opening a replacement dialog while another is open must never leave the + * replacement's close restoring focus into the dialog it hid. openModal + * used to capture the return target after closing the previous dialog, so + * a replacement opened from a control inside the previous dialog saved + * that hidden control as its return target. + * + * Both tests fail on the pre-fix shell (base head 6dd8edb) and pass after. + */ + +const WALKTHROUGH = "#walkthrough-modal"; +const API_KEYS = "#api-keys-modal"; +const TRIGGER = "#gate-test-trigger"; + +async function waitForApp(page) { + await page.waitForFunction( + () => + typeof window.openWalkthroughModal === "function" && + typeof window.openApiKeysModal === "function", + ); +} + +test.describe("shared modal shell gate behaviour", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("hasSeenWalkthrough", "true")); + await applyDefaultRoutes(page, { identity: guestIdentity() }); + }); + + test("a repeated open of the active modal keeps the page scroll lock and background state intact", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + + expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden"); + + // Open through the app path, then re-open the SAME modal — exactly what the + // walkthrough's blur and delayed Tab handlers do while the tour is already up. + await page.evaluate(() => window.openWalkthroughModal()); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + expect(await page.evaluate(() => document.body.style.overflow)).toBe("hidden"); + + await page.evaluate(() => window.openWalkthroughModal()); + expect(await page.evaluate(() => document.body.style.overflow)).toBe("hidden"); + + await page.evaluate(() => window.closeWalkthroughModal()); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + + // The close restores the value saved before the FIRST open, not "hidden". + expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden"); + + // The background is interactive again: not inert and not aria-hidden. + const background = await page.evaluate(() => { + const header = document.querySelector("header.topbar"); + return { inert: header.inert, ariaHidden: header.getAttribute("aria-hidden") }; + }); + expect(background.inert).toBe(false); + expect(background.ariaHidden).toBeNull(); + }); + + test("a replacement dialog never returns focus into the dialog it hid", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + + // A real trigger outside the dialog, focused before the first open, becomes + // the walkthrough's return target. + await page.evaluate(() => { + document.getElementById("gate-test-trigger")?.remove(); + const trigger = document.createElement("button"); + trigger.id = "gate-test-trigger"; + trigger.textContent = "Open tour"; + document.body.append(trigger); + trigger.focus(); + window.openWalkthroughModal(); + }); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await page.waitForFunction( + (id) => document.getElementById(id)?.contains(document.activeElement), + "walkthrough-modal", + { timeout: 3000 }, + ); + + // Replace the tour while focus lives inside it. The shared editor opens + // without pre-closing the tour (openApiKeysModal is the standalone path), + // so this exercises the replacement branch of openModal. + await page.evaluate(() => window.openApiKeysModal()); + await expect(page.locator(API_KEYS)).toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + + await page.evaluate(() => window.closeApiKeysModal()); + await expect(page.locator(API_KEYS)).not.toHaveClass(/open/); + + // The replacement's close returns focus to what opened the tour — never to + // a control inside the dialog it hid. + const focused = await page.evaluate(() => ({ + id: document.activeElement?.id || "", + insideWalkthrough: document + .getElementById("walkthrough-modal") + .contains(document.activeElement), + })); + expect(focused.insideWalkthrough).toBe(false); + expect(focused.id).toBe("gate-test-trigger"); + await expect(page.locator(TRIGGER)).toBeFocused(); + }); +}); diff --git a/e2e/stu391-tutorial-onboarding.spec.js b/e2e/stu391-tutorial-onboarding.spec.js index 1702317..dfab665 100644 --- a/e2e/stu391-tutorial-onboarding.spec.js +++ b/e2e/stu391-tutorial-onboarding.spec.js @@ -8,6 +8,7 @@ import { sampleProjectList, ENDPOINTS, } from "./fixtures/apiFixtures.js"; +import { PLAN_LIMITS } from "../src/plansSurface.js"; /** * STU-391 — tutorials and connection onboarding in the new visual system. @@ -107,7 +108,10 @@ async function clickInsideWalkthrough(page, selector) { // may not have wired openWalkthroughModal yet, so a click can land while the // handler is undefined and silently navigate to #tutorial instead of opening // the modal — wait for the wire-up (state) before clicking, then wait for the -// open animation to finish. +// open animation to finish. The startup usage decision is deliberately NOT +// awaited: with the app fix a passive exhausted resolution no longer closes an +// open tour, so the click may land before or after the decision — the race +// itself is exercised, and the tour survives either order. async function reopenWalkthrough(page) { await page.waitForFunction( () => typeof window.openWalkthroughModal === "function", @@ -339,4 +343,104 @@ test.describe("STU-391 tutorial + connection onboarding", () => { await page.waitForTimeout(100); } }); + + // Contract: the startup usage decision is PASSIVE, and a passive exhausted + // decision must never take a tour the user is reading away from them. + // resolveIdentity lands its metered count asynchronously and, when that count + // is exhausted, renders the paywall state through updateUsageDisplay -> + // showPaywallExhausted. updateUsageDisplay defers the ENTIRE paywall while + // any dialog is open, so the exhausted surface is held back — it is not + // rendered alongside the tour, and the tour is not closed either. The identity + // response is gated so the tour is guaranteed open when the decision arrives + // (the exact sequence the original flake pinned), and the persisted metered + // count is cleared first so "the decision landed" is observed, not assumed: + // resolveIdentity writes it and calls updateUsageDisplay in one synchronous + // block, so the key reappearing at the limit means the decision has fully run. + // Once no dialog is open, the very next usage decision DOES surface the + // paywall — the deferral is a delay, never a suppression. A run attempt is not + // passive: an out-of-runs run must still surface the paywall, take the tour + // with it, and open pricing. + test("the startup paywall decision defers the paywall while a tour is open; an out-of-runs run attempt still surfaces it", async ({ page }) => { + let releaseIdentity; + const identityGate = new Promise((resolve) => { + releaseIdentity = resolve; + }); + await applyDefaultRoutes(page); + // Hold the identity response until the tour is open, then answer with a + // free user at the tier limit. Registered after applyDefaultRoutes, which + // matches routes in reverse registration order, so this handler wins. + await page.route(ENDPOINTS.identity, async (route) => { + await identityGate; + await route.fulfill({ + status: 200, + body: JSON.stringify({ + status: "guest", + user_id: "guest-exhausted-0001", + identity_token: null, + usage_count: PLAN_LIMITS.free, + usage_month: new Date().toISOString().slice(0, 7), + }), + contentType: "application/json", + }); + }); + + await page.goto("/"); + + // Fresh user: the tour auto-opens before resolveIdentity has fetched, so it + // is already open when the decision lands. + await waitForWalkthroughOpen(page); + // Nothing has been metered yet at this point, so dropping the key leaves the + // decision's own write as the only thing that can satisfy the wait below. + await page.evaluate(() => localStorage.removeItem("ccc_usage")); + releaseIdentity(); + + // The decision has landed — the exhausted count is persisted. + await page.waitForFunction( + (limit) => { + const raw = localStorage.getItem("ccc_usage"); + if (!raw) return false; + try { + return JSON.parse(raw).count >= limit; + } catch { + return false; + } + }, + PLAN_LIMITS.free, + { timeout: 8000 }, + ); + + // The passive decision defers the paywall entirely while the tour is open: + // the exhausted surface is not rendered, and the tour the user is reading + // is neither hidden nor closed. + await expect(page.locator("#paywall-exhausted")).toBeHidden(); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).toHaveAttribute("aria-hidden", "false"); + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + + // The tour is closed the way a user closes it, so the next startup decision + // runs with no dialog open. + await page.check("#walkthrough-dont-show"); + await clickInsideWalkthrough(page, ".wt-gotit-btn"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + // Deferral is a delay, not a suppression: the next passive usage decision + // with nothing open renders the exhausted surface. + await page.reload(); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + await expect(page.locator("#paywall-exhausted")).toBeVisible(); + + // A run attempt is not passive: an out-of-runs run must surface the paywall + // in place of the tour, and open pricing so the user can act on it. + await reopenWalkthrough(page); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + + await page.evaluate(() => { + document.getElementById("pipeline-input").value = "A circular progress gauge"; + window.runThinkingPipeline(); + }); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).toHaveAttribute("aria-hidden", "true"); + await expect(page.locator("#paywall-exhausted")).toBeVisible(); + await expect(page.locator("#pricing-modal")).toHaveClass(/open/); + }); });