144 lines · 7.3 KB · TypeScript
RawDownload
// Cloud-specific (browser): switching organizations changes the active workspace.// A fresh user creates two organizations through the real web UI — the first// via onboarding and the second via the account-menu → org switcher → "Create// organization" modal — then uses the same switcher to return to the first org// and confirms the workspace label in the bottom-left account button updates.import { expect } from "@effect/vitest";import { Effect } from "effect"; import { scenario } from "../src/scenario";import { Browser, Target } from "../src/services";import { visit, settle } from "../src/surfaces/browser"; scenario(  "Organizations · switching organizations switches the workspace",  {},  Effect.gen(function* () {    const target = yield* Target;    const browser = yield* Browser;    const identity = yield* target.newIdentity({ org: false });     yield* browser.session(identity, async ({ page, step }) => {      // ── Step 1: onboarding, create the first org ─────────────────────      await step("Fresh user lands on onboarding (no organization yet)", async () => {        await visit(page, "/");        await page.getByPlaceholder("Northwind Labs").waitFor();      });       const ORG_1 = "Switcher Org One";      const ORG_2 = "Switcher Org Two";       await step(`Create "${ORG_1}" via onboarding`, async () => {        await page.getByPlaceholder("Northwind Labs").fill(ORG_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 before opening menus — a late        // remount closes them mid-interaction. The console canonicalizes onto        // the org's URL slug (/switcher-org-one).        await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {          timeout: 30_000,        });        await settle(page);      });       // ── Step 2: create the second org via the account-menu switcher ──      await step('Open the org switcher and choose "Create organization"', async () => {        // Bounded retry: under parallel-suite load the radix menu re-renders        // while the org list loads and a click can land on a closing menu.        for (let attempt = 1; ; attempt++) {          try {            await page.keyboard.press("Escape");            await page.getByRole("button", { name: /Test User/ }).click();            await page.getByRole("menuitem", { name: ORG_1 }).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 });            break;          } catch (error) {            if (attempt >= 3) throw error;          }        }      });       await step(`Create "${ORG_2}" via the org switcher modal`, async () => {        await page.getByPlaceholder("Northwind Labs").fill(ORG_2);        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" });        // Confirm the account button now shows ORG_2.        await page.getByRole("button", { name: new RegExp(ORG_2) }).waitFor();      });       // Capture the label while we are in ORG_2 as a baseline.      const labelAfterOrg2 = await page        .getByRole("button", { name: new RegExp(ORG_2) })        .innerText();      expect(labelAfterOrg2, "account button shows the second org after creation").toContain(ORG_2);       // ── Step 3: switch back to the first org ─────────────────────────      // The org-switcher sub-menu shows org IDs (not names) because the stub's      // getOrganization returns the ID as the name. The currently-active org is      // rendered with data-disabled="" (Radix convention). The only item without      // data-disabled that isn't "Create organization" is ORG_1.      await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => {        await settle(page);        await page.getByRole("button", { name: /Test User/ }).click();        // Click the SubTrigger (shows current org name = ORG_2) to expand the list.        await page.getByRole("menuitem", { name: ORG_2 }).click();        // Wait for the sub-content to open.        await page.locator('[data-slot="dropdown-menu-sub-content"]').waitFor({ state: "visible" });        // The organizationsAtom loads asynchronously — wait until the loading state        // clears and the org items appear. The org items have data-disabled="" when        // active and no data-disabled when not. "Create organization" is always shown        // and always enabled; wait until there are at least 2 non-disabled items        // (the non-active org + "Create organization") before clicking.        await page          .locator('[data-slot="dropdown-menu-sub-content"]')          .locator('[role="menuitem"]:not([data-disabled])')          .nth(1)          .waitFor();        // Now the sub-content has loaded. The org items appear BEFORE the separator and        // "Create organization". ORG_1 (non-active, not disabled) appears before ORG_2        // (active, disabled) and before "Create organization". Click the first        // non-disabled item that is NOT "Create organization" — that is ORG_1.        await page          .locator('[data-slot="dropdown-menu-sub-content"]')          .locator('[role="menuitem"]:not([data-disabled])')          .filter({ hasNot: page.getByText("Create organization") })          .first()          .click();        // The menu closes, the page reloads, and the session switches into ORG_1.        await page.getByRole("button", { name: new RegExp(ORG_1) }).waitFor();      });       // ── Assert: workspace label reflects the first org ───────────────      const labelAfterSwitch = await page        .getByRole("button", { name: new RegExp(ORG_1) })        .innerText();      expect(labelAfterSwitch, "account button shows the first org after switching back").toContain(        ORG_1,      );       // Cross-check the active org through the session API.      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 }>;        activeOrganizationId?: string;      };      expect(response.ok).toBe(true);      expect(body.organizations.length, "exactly two organizations exist for this user").toBe(2);    });  }),);