Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions e2e/gate-modal-behavior.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
106 changes: 105 additions & 1 deletion e2e/stu391-tutorial-onboarding.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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/);
});
});
Loading