// Cloud-specific (billing): the free plan allows 3 organizations per user. // Driven ENTIRELY through the real web UI as a fresh user — the onboarding // create-org page for the first org, then the in-app account-menu → // org-switcher → "Create organization" modal for the rest. The run's // Playwright trace + video + step screenshots are the debugging artifacts. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Billing, Browser, Target } from "../src/services"; const FREE_LIMIT = 3; scenario( "Billing · the free plan stops organization creation after 3", {}, Effect.gen(function* () { // Gate: billing limits are enforced on this target. yield* Billing; const target = yield* Target; const browser = yield* Browser; const identity = yield* target.newIdentity({ org: false }); yield* browser.session(identity, async ({ page, step }) => { await step("A fresh user lands on onboarding (no organization yet)", async () => { await page.goto("/", { waitUntil: "networkidle" }); await page.getByPlaceholder("Northwind Labs").waitFor(); }); await step(`Create "Acme 1" (1 of ${FREE_LIMIT} allowed on the free plan)`, async () => { await page.getByPlaceholder("Northwind Labs").fill("Acme 1"); await page.getByRole("button", { name: "Create organization" }).click(); // Onboarding step 2 — proves the first org was created. await page.getByText("Connect your MCP client").waitFor(); }); await step("Continue into the app", async () => { await page.getByRole("button", { name: "Continue to app" }).click(); await page.getByText("Integrations").first().waitFor(); // Let the router navigation fully settle (slow on a cold dev server) // before opening menus — a late remount closes them mid-interaction. // The console canonicalizes onto the org's URL slug (/acme-1). await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); await page.waitForLoadState("networkidle"); }); const openCreateOrgModal = async (currentOrg: string) => { // Under parallel-suite load the radix menu re-renders while the org // list loads; a click can land on a closing menu. Bounded retry with // a clean reopen keeps the journey honest without 30s hangs. for (let attempt = 1; ; attempt++) { try { await page.keyboard.press("Escape"); await page.getByRole("button", { name: /Test User/ }).click(); await page.getByRole("menuitem", { name: currentOrg }).click({ timeout: 5_000 }); const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); await subContent.waitFor({ state: "visible", timeout: 5_000 }); await subContent .getByText("Create organization", { exact: true }) .click({ timeout: 5_000 }); await page.getByText("Add another organization").waitFor({ timeout: 5_000 }); return; } catch (error) { if (attempt >= 3) throw error; } } }; for (let i = 2; i <= FREE_LIMIT; i++) { await step(`Open the org switcher and choose "Create organization"`, async () => { await openCreateOrgModal(`Acme ${i - 1}`); }); await step(`Create "Acme ${i}" (${i} of ${FREE_LIMIT})`, async () => { await page.getByPlaceholder("Northwind Labs").fill(`Acme ${i}`); await page.getByRole("button", { name: "Create organization" }).click(); // The modal closes and the session switches into the new org. await page.getByText("Add another organization").waitFor({ state: "hidden" }); await page.getByRole("button", { name: new RegExp(`Acme ${i}`) }).waitFor(); }); } await step("Attempt a 4th organization (over the free limit)", async () => { await openCreateOrgModal(`Acme ${FREE_LIMIT}`); await page.getByPlaceholder("Northwind Labs").fill("Acme 4"); await page.getByRole("button", { name: "Create organization" }).click(); await page.locator("p.text-destructive").first().waitFor(); }); const errorText = await page.locator("p.text-destructive").first().innerText(); expect(errorText.length, "the UI shows a visible refusal").toBeGreaterThan(0); // Cross-check through the session API, with the browser's own session // cookie (fetched explicitly — the Secure cookie isn't replayed by // page.request over plain http). const cookie = (await page.context().cookies()).map((c) => `${c.name}=${c.value}`).join("; "); const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), { headers: { cookie }, }); const body = (await response.json()) as { organizations: ReadonlyArray<{ name: string }> }; expect(body.organizations.length, "exactly the free-plan allowance exists").toBe(FREE_LIMIT); }); }), );