From 43c5a8948d3cda704ac451593d0a03688a569423 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 13:39:19 -0400 Subject: [PATCH 01/11] test(mosaic): run feature tests against real Clerk in the browser --- .changeset/mosaic-feature-tests.md | 2 + .claude/skills/mosaic/SKILL.md | 8 +- .../skills/mosaic/references/controllers.md | 12 +- .claude/skills/mosaic/references/migration.md | 14 +- .claude/skills/mosaic/references/models.md | 11 +- .claude/skills/mosaic/references/testing.md | 315 +++---- .claude/skills/mosaic/references/views.md | 6 +- .github/workflows/ci.yml | 36 + .gitignore | 1 + packages/mosaic/package.json | 9 +- .../mosaic/src/__tests__/feature/fake-fapi.ts | 186 +++++ packages/mosaic/src/__tests__/feature/fapi.ts | 394 +++++++++ .../mosaic/src/__tests__/feature/render.tsx | 36 + .../__tests__/user-button.feature.test.tsx | 595 ++++++++++++++ .../user-button.integration.test.tsx | 773 ------------------ .../user-profile-profile-panel.styles.ts | 3 - .../mosaic/test/public/mockServiceWorker.js | 343 ++++++++ packages/mosaic/vitest.config.mts | 37 +- packages/mosaic/vitest.setup.browser.mts | 27 + packages/mosaic/vitest.setup.mts | 34 + pnpm-lock.yaml | 204 ++++- references/mosaic-architecture.md | 25 +- 22 files changed, 2056 insertions(+), 1015 deletions(-) create mode 100644 .changeset/mosaic-feature-tests.md create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi.ts create mode 100644 packages/mosaic/src/__tests__/feature/fapi.ts create mode 100644 packages/mosaic/src/__tests__/feature/render.tsx create mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx create mode 100644 packages/mosaic/test/public/mockServiceWorker.js create mode 100644 packages/mosaic/vitest.setup.browser.mts create mode 100644 packages/mosaic/vitest.setup.mts diff --git a/.changeset/mosaic-feature-tests.md b/.changeset/mosaic-feature-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-feature-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index cb31f29d47f..263cb8a65e0 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -5,7 +5,7 @@ description: >- tokens, `themeProps`), or building a flow — writing the model (the Clerk adapter), the controller (local state, in React state or a state machine: `setup`, states/guards/`invoke`, wired to React with `useMachine`/`useActor`/ - `useSelector`), or the view (rendering), testing any of those layers, or + `useSelector`), or the view (rendering), testing a feature, or migrating a legacy / pre-Mosaic component into the model / controller / view split. Use when building, styling, debugging, testing, or migrating anything Mosaic. `references/mosaic-architecture.md` (repo root) holds the design-system @@ -57,8 +57,8 @@ architecture" section that defines the split. Read it for the _what_; this skill is the _how-to_. `packages/mosaic/src/features/user-button/` is the fullest worked example of the split -in the repo — model, controller, view, wrapper, types, messages, and a test per -layer. Copy from it. +in the repo — model, controller, view, wrapper, types, messages, and a feature +test. Copy from it. ## Which reference to read @@ -71,7 +71,7 @@ layer. Copy from it. | Writing the controller (local state, pending, action wrapping) | `references/controllers.md` | | Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | | Writing the view (rendering plain props) | `references/views.md` | -| Testing a model, controller, or view | `references/testing.md` | +| Testing a feature (feature tests, unit tests) | `references/testing.md` | | Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` | | Running the parity audit that guards a migration | `references/parity-audit.md` | diff --git a/.claude/skills/mosaic/references/controllers.md b/.claude/skills/mosaic/references/controllers.md index 804f1b823d7..59d5011827a 100644 --- a/.claude/skills/mosaic/references/controllers.md +++ b/.claude/skills/mosaic/references/controllers.md @@ -6,8 +6,8 @@ the model's actions so the surface can report and survive them. It does **not** touch Clerk. Its effects arrive as injected plain functions: from a model (`models.md`) when a wrapper composes the two, or as a prop when a -leaf view calls its own controller. That is what lets a controller test run -against a fake object instead of a mocked Clerk. +leaf view calls its own controller. That keeps the controller independent of +where its effects come from. Worked examples: @@ -122,7 +122,7 @@ have a machine. ## Testing -Feed the controller a **fake model object** — a plain literal with -`status: 'ready'` and `vi.fn()` callbacks — and render a tiny harness that -surfaces what it returns. No Clerk mocking. Assert the pending key, what closes -the surface, and that an absent model callback stays absent. See `testing.md`. +The controller is covered by the feature test, not a test of its own. Hold the +FAPI request an action makes to assert its in-flight state (the spinner, the rows +stood down), then release or fail it to assert what closes the surface. See +`testing.md`. diff --git a/.claude/skills/mosaic/references/migration.md b/.claude/skills/mosaic/references/migration.md index 5e7d0445e9b..c1f80487c6f 100644 --- a/.claude/skills/mosaic/references/migration.md +++ b/.claude/skills/mosaic/references/migration.md @@ -66,7 +66,7 @@ Two rows deserve extra care because they have no obvious home: - **Pure derivation** (slot layout, ordering a consumer's list) belongs in `*.layout.ts` / `*.utils.ts` beside the view, where it gets its own test. -## Phase 3 — Implement and test per layer +## Phase 3 — Implement and test File shape: `.model.tsx` · `.controller.tsx` · `.view.tsx` · `.tsx` (composition wrapper), plus @@ -78,15 +78,15 @@ whether the interaction has the async lifecycle and mutually-constraining values that earn a machine, or whether it is `useState` (`controllers.md` → "Which one holds the state"). -Each layer is testable in isolation — that isolation is what makes the migration -verifiable. Follow the recipes in `testing.md`: the model against a mocked Clerk, -the controller against a fake model object, the view against plain props. The -**model** is the highest-risk, least-covered layer — concentrate scrutiny there. -Finish with one `*.integration.test.tsx` proving the layers compose. +Test the feature with one `*.feature.test.tsx` that uses it the way a user +would, against a real Clerk with FAPI faked (`testing.md`). Turn each inventory +row into a test there, not into per-layer tests. The **model** is the +highest-risk layer, so make sure its rows (revalidation, permission gates, +empty states) each have one. ## Phase 4 — Verify parity (the confidence step) -Machine and view tests only cover branches you remembered to write. To catch the +Tests only cover branches you remembered to write. To catch the ones you didn't, run an automated diff of legacy against new. Launch an **Explore subagent** with the prompt in `parity-audit.md`. Give it the diff --git a/.claude/skills/mosaic/references/models.md b/.claude/skills/mosaic/references/models.md index f685f84ac73..a1c0465d2e8 100644 --- a/.claude/skills/mosaic/references/models.md +++ b/.claude/skills/mosaic/references/models.md @@ -89,9 +89,8 @@ drops the fallback instead of holding the space open. Keep the two apart. ## Testing -Mock `@clerk/shared/react` with mutable module-level vars reset in `beforeEach`, -then `renderHook` the model and assert its output. This is the **highest-risk, -least-covered layer**: it holds the Clerk resource semantics no other test can -reach. When a migration loses behavior, it is usually a model responsibility -(revalidate timing, a permission gate, an empty-state rule) that quietly went -missing — concentrate scrutiny here. See `testing.md`. +The model is covered by the feature's feature test, which runs it against a real +Clerk with FAPI faked, rather than by a test of its own. It is the +**highest-risk layer**: when a migration loses behavior, it is usually a model +responsibility (revalidate timing, a permission gate, an empty-state rule) that +quietly went missing, so give those cases a feature test each. See `testing.md`. diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 1b80035d6c9..758261cba57 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -1,194 +1,149 @@ # Testing a Mosaic flow -A flow is three layers (`models.md` · `controllers.md` · `views.md`), and each is -tested in isolation. That isolation is the point: **only the model test mocks -Clerk**. The controller runs against a fake model object, and the view runs -against plain props. - -Tests are Vitest + React Testing Library, co-located in `__tests__/` next to the -feature and named for the layer they cover. - -`packages/mosaic/src/features/user-button/__tests__/` is the canonical set to copy from: - -| File | Covers | -| ---------------------------------- | ----------------------------------------------------------- | -| `user-button.model.test.tsx` | Clerk → plain data. The only file that mocks Clerk. | -| `user-button.controller.test.tsx` | Fake model → view props. Pending, closing, gating. | -| `user-button.view.test.tsx` | Plain props → DOM. What each surface carries and withholds. | -| `user-button.test.tsx` | The wrapper's branching, with all three layers mocked out. | -| `user-button.integration.test.tsx` | Real layers against a mocked Clerk, driving the real DOM. | -| `user-button.layout.test.ts` | Pure derivation, no React. | -| `user-button.utils.test.ts` | Pure helpers, no React. | - -Shared helpers live in `packages/mosaic/src/machines/__tests__/test-utils.ts`: -`deferred()` (a promise whose `resolve`/`reject` are captured, so you can -assert an in-flight state before settling it), `tick()` (flush microtasks so an -`invoke`'s `onDone`/`onError` runs), and `noop`. - -Run one file with `pnpm --filter @clerk/ui test `. - ---- - -## Model — mock Clerk, assert the plain data - -Mock `@clerk/shared/react` with mutable module-level vars reset in `beforeEach`, -so a test opts into a condition by setting one flag rather than rewriting the -mock. Build the environment **per read**, not once, or a flag set inside a test -won't be seen: - -```tsx -let isUserLoaded: boolean; -let user: FakeUser | null; -let singleSessionMode: boolean; -let environmentHydrated: boolean; - -// Built per read rather than once, so a test setting any of the flags above is answered by it. -function environment() { - return environmentHydrated - ? { displayConfig: { branded }, authConfig: { singleSessionMode }, organizationSettings: { enabled: true } } - : null; -} - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, - // rather than that some function did. - usePortalRoot: () => getContainer, - useClerk: () => ({ setActive, signOut, buildSignInUrl: () => '/sign-in', __internal_environment: environment() }), - }; -}); +We follow [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests): + +- **Mostly integration.** Most tests render the real feature against a real + Clerk and use it the way a user would. +- **Not too many.** Each test should buy confidence that the feature works for + a user. Past that point, tests cost more than they return. +- **Don't test implementation details.** A test that breaks on a refactor that + keeps the behavior is testing the wrong thing. +- **Static checks come first.** TypeScript and ESLint catch typos and shape + errors, so tests don't need to. + +A flow is three layers (`models.md` · `controllers.md` · `views.md`), but it is +**not** tested per layer. The props between layers are internal contracts that +change every time the flow grows, so tests pinned to them get rewritten with +every change and prove little about what the user sees. Test at the two stable +boundaries instead: + +- **In:** the Frontend API (FAPI), the HTTP contract Clerk talks to. +- **Out:** what the user sees and can do (roles, text, focus). + +Rule of thumb: **test each behavior once, at the boundary that owns it.** + +## Three tiers + +| Tier | Files | Runs in | Covers | +| ------- | --------------------------- | -------------------------------------- | ----------------------------------------------------------------------- | +| Unit | `*.test.ts(x)` | jsdom (`--project mosaic`) | Pure helpers, shared primitives (`useForm`, `Dialog`, machines), timing | +| Feature | `*.feature.test.tsx` | Chromium via Vitest browser mode | A feature end to end: real `Clerk`, real layers, FAPI faked with MSW | +| E2E | `/integration` (Playwright) | Real apps against a real Clerk backend | Framework wiring, redirects, real sessions. Not written per Mosaic flow | + +Visual states (loading, empty, every error, every variant) are swingset stories +with plain props, reviewed by eye. Don't duplicate them as view tests. + +Do **not** add per-layer model, controller, view, or wrapper tests with mocked +layers. If a behavior is visible to the user, the feature test owns it. Some +features still carry per-layer tests from before this rule; delete them once a +feature test covers the same behavior. + +## Running + +```bash +pnpm --filter @clerk/mosaic test # unit tests (jsdom and primitives) +pnpm --filter @clerk/mosaic test:feature # feature tests (Chromium) +pnpm --filter @clerk/mosaic test:feature user-button # one feature +pnpm --filter @clerk/mosaic exec vitest run # every project ``` -Stub at the **helper the model actually reads through**, not one layer deeper — -the paginated lists come from `useOrganizationListInView`, so that is the fetch -boundary to mock. - -Then `renderHook(() => useUserButtonModel(options))` and assert: +CI runs feature tests in their own `Mosaic Feature Tests` job, so the repo-wide +unit test job never needs a browser. -- `status` is `loading` until every load flag that affects layout has answered, - and `hidden` — not `loading` — once Clerk says nobody is signed in. -- A capability the instance lacks makes its callback `undefined` - (`singleSessionMode` → no `onSignOutAll`; no permission → no `onInviteMembers`). -- Calling a callback reaches Clerk with the right arguments, and revalidates - after the mutation. +Feature tests need Chromium: run `pnpm playwright install chromium` once. They +import the built `@clerk/clerk-js`, so run `pnpm turbo build --filter=@clerk/clerk-js` +after changing it. Failure screenshots land in `packages/mosaic/.vitest/`. +Console output from passing tests only shows with `--reporter=verbose`. -These are the assertions no other layer can make. When a migration loses -behavior, it is almost always here. +## Feature tests -## Controller — fake model, no Clerk - -Build a `ready()` factory that returns a plain model literal with `vi.fn()` -callbacks, render a harness that surfaces what the controller returns, and drive -it. There is no Clerk mocking in this file at all: +A feature test runs the real `Clerk` from `@clerk/clerk-js` in Chromium. MSW +answers its FAPI requests from a service worker, so everything from Clerk's +resource classes up to the rendered DOM is production code. ```tsx -function ready(overrides: Partial = {}): UserButtonReadyModel { - return { - status: 'ready', - organizationsEnabled: true, - activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, - memberships: [], - additionalSessions: [], - ...overrides, - }; -} - -function Harness({ model, ...options }: { model: UserButtonModel } & UserButtonControllerOptions) { - const c = useUserButtonController(model, options); - if (c.status !== 'ready') return {c.status}; - return ( -
- {String(c.open)} - {c.pendingKey ?? ''} - -
- ); -} - -it('runs a model action through the machine and keys the affordance', async () => { - const onSelectOrganization = vi.fn(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('select-org')); - - expect(onSelectOrganization).toHaveBeenCalledWith('org_1'); - await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); - - await act(async () => { - await tick(); +it('makes the selected organization active and closes', async () => { + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' })]), + memberships: [fapiMembership(acme), fapiMembership(other)], }); - expect(screen.getByTestId('open')).toHaveTextContent('false'); -}); -``` + await renderWithClerk(); + const user = userEvent.setup(); -Assert what only the controller decides: `loading`/`hidden` passing through, which -actions close the surface and which leave it open, that a hand-off closes _before_ -it runs, that an absent model callback stays absent, and that a second action -cannot start while one is in flight. Use `deferred()` + `act()` to hold an effect -open and observe the in-flight state. - -For a transient state you can't easily drive to, teleport in with -`mockActor(machine, { value, context })` — see `machine/README.md` → -"Testing & docs". - -## View — plain props, no Clerk, no machine - -Pass plain data and `vi.fn()` callbacks. Assert what renders and that the right -callback fires. **Wrap in ``** — it is not a Clerk provider; it -supplies the icon-override context, and wrapping keeps the test tree matching -production. - -Give the fixture **every** callback by default, so a test opts a surface _out_ of -an affordance rather than having to opt into it — that way "this row is absent" -is an explicit assertion rather than an accident of the fixture. - -```tsx -const alice = { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }; - -function renderView(overrides: Partial = {}) { - const props = { ...allCallbacks, activeSession: alice, memberships: [], ...overrides }; - render( - - - , - ); - return props; -} - -it('omits sign-out-of-all when the instance does not offer it', async () => { - renderView({ onSignOutAll: undefined }); - await userEvent.click(screen.getByRole('button', { name: /open account menu/i })); - expect(screen.queryByText(m.footer.signOutAll)).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Open account menu/ })); + await user.click(await screen.findByRole('button', { name: 'Other' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); }); ``` -Because the view is pure rendering, a test can assert "props X render element Y -and clicking Z calls W" for every branch without any of the flow or data -machinery. - -## Wrapper — mock all three layers - -The wrapper's own job is only which status renders what, so mock the model, -controller, and view out and assert the branching: - -```tsx -vi.mock('../user-button.model', () => ({ useUserButtonModel: () => ({ status: 'loading' }) })); -vi.mock('../user-button.controller', () => ({ useUserButtonController: () => controller })); -vi.mock('../user-button.view', () => ({ UserButtonView: () => })); -``` - -## Integration — real layers, mocked Clerk - -One file per connected component, proving the layers compose. It mocks Clerk the -same way the model test does, then renders the real wrapper and drives the real -DOM with `userEvent`. It is the only place that catches wiring bugs the isolated -tests each pass: an action that should close the popup but doesn't, a model -callback the controller forgot to wrap, a prop name that drifted between layers. - -Keep it about **composition**, not coverage — the per-layer tests own the -branches. +`packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx` +is the worked example. + +### The toolkit (`src/__tests__/feature/`) + +- **`fapi.ts`**: typed builders for FAPI JSON (`fapiEnvironment`, `fapiUser`, + `fapiSession`, `fapiClient`, `fapiOrganization`, `fapiMembership`, + `fapiInvitation`, `fapiSuggestion`, `fapiToken`). They return the wire shapes + from `@clerk/shared/types`, so a fixture can't invent a field. + `fapiEnvironment(overrides)` shallow-merges per section, for example + `{ organization_settings: { enabled: false } }`. +- **`fake-fapi.ts`**: + - `serveFapi(seed)` registers stateful handlers and returns the live state. + Mutations (touch, sign-out, accept) update it, so later reads see them. A + test can assert on it (`fapi.client.sessions`) or change it. + - `holdRequests(method, path)` holds matching requests open. Assert the + in-flight UI, then `release()` to let them through to `serveFapi`, or + `fail(code)` to answer with a 400 Clerk error. +- **`render.tsx`**: `renderWithClerk(ui)` renders inside `ClerkContextProvider` + and `MosaicProvider`, loads Clerk, and returns `{ clerk, navigate, ... }`. + `navigate` is the router Clerk was loaded with, called with the path. + +The setup file (`vitest.setup.browser.mts`) starts the worker and focuses the +window. After each test it cleans up, resets the handlers and the shared query +cache, and **fails the test on any FAPI request without a handler**. When a +feature needs a new endpoint, add a handler to `serveFapi` that mirrors what +FAPI returns. + +### Order matters + +Call `serveFapi`, then any `holdRequests`, then `renderWithClerk`. Handlers +added later win, so a hold must come after the defaults it overrides. To hold a +request made during a user action, add the hold after the render and before the +click. + +### Guidelines + +- **Query like a user.** Roles, labels, and text. Avoid test ids. +- **Assert outcomes.** What the screen shows, where the user was sent + (`navigate`), and what FAPI now holds. Never a controller's state or a prop. +- **Spy only at the edge of the feature.** Spy on modals another package owns + (`clerk.openUserProfile`, `openCreateOrganization`) with + `vi.spyOn(clerk, ...).mockImplementation(() => {})`. Don't mock Clerk hooks + or Mosaic modules. +- **Wait for things to disappear.** Popovers and dialogs run exit transitions + in a real browser, so use `waitFor(() => expect(...).toBeNull())`. +- **One `describe` per user task**, so the file reads like the feature's spec. + +### Clerk behaviors worth knowing + +- `setActive` only touches the session when the document has focus. The setup + focuses the window, so don't blur it by accident. +- clerk-js treats a **422** as "unauthenticated" and refetches the client. Use + 400s for ordinary failures, which is what `holdRequests(...).fail()` sends. +- After a touch, `getToken` is served from the token the touch returned, so + there is no tokens request to hold during an organization switch. To observe + Clerk's transitive state, route the action through navigation and make + `navigate` return a pending promise. + +## Unit tests + +Write unit tests for: + +- Pure helpers, such as a feature's `*.layout.ts`. +- Shared primitives many features use (`useForm`, `Dialog`, the machine + library). These are stable contracts inside the package. +- Timing that is slow to drive through the UI, such as a resend countdown. Use + fake timers. + +`packages/mosaic/src/machines/__tests__/test-utils.ts` has `deferred()`, +`tick()`, and `noop`. diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index 3a6a9cca927..aedf67aef06 100644 --- a/.claude/skills/mosaic/references/views.md +++ b/.claude/skills/mosaic/references/views.md @@ -124,9 +124,7 @@ every successful removal drops focus on the floor. ## Testing -Render the view directly with **plain props and `vi.fn()` callbacks**. No Clerk -providers, no fixtures, no machine. **Wrap in ``** — it is not a -Clerk provider; it supplies the icon-override context, and wrapping keeps the -test tree matching production. See `testing.md`. +The view's behavior (roles, labels, focus) is covered by the feature test. Its +visual states are swingset stories with plain props. See `testing.md`. See `references/mosaic-architecture.md` → "Views" for the layer contract. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 615da0d802f..9db0012ccec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,6 +324,42 @@ jobs: path: .turbo/runs retention-days: 5 + mosaic-feature-tests: + needs: [check-permissions, build-packages] + name: Mosaic Feature Tests + permissions: + contents: read + runs-on: 'blacksmith-8vcpu-ubuntu-2204' + defaults: + run: + shell: bash + timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }} + + steps: + - name: Checkout Repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 1 + fetch-tags: false + filter: 'blob:none' + show-progress: false + + - name: Setup + uses: ./.github/actions/init-blacksmith + with: + cache-enabled: true + playwright-enabled: true + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + turbo-team: ${{ vars.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + + - name: Build Mosaic and its dependencies + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/mosaic... + + - name: Run feature tests + run: pnpm --filter @clerk/mosaic test:feature + integration-tests: needs: [check-permissions, build-packages] if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.gitignore b/.gitignore index 47d16f5917a..24d1b33d3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,7 @@ yalc.lock # Workflow Outputs /packages/*/*.tgz /packages/*/tsconfig*.vitest-temp.json +/packages/*/.vitest/ /packages/*/tsup.config.bundled_* # Scripts diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index e7aa9d63c84..51b4e48ea3e 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -57,8 +57,9 @@ "lint": "eslint src", "lint:attw": "attw --pack . --exclude-entrypoints styles.css --profile esm-only", "lint:publint": "publint", - "test": "vitest run", - "test:ci": "vitest run --maxWorkers=70%", + "test": "vitest run --project primitives --project mosaic", + "test:ci": "vitest run --project primitives --project mosaic --maxWorkers=70%", + "test:feature": "vitest run --project feature", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -66,6 +67,7 @@ "@floating-ui/react": "catalog:repo" }, "devDependencies": { + "@clerk/clerk-js": "workspace:^", "@stylexjs/eslint-plugin": "0.19.0", "@stylexjs/rollup-plugin": "0.19.0", "@stylexjs/stylex": "0.19.0", @@ -76,9 +78,12 @@ "@testing-library/user-event": "^14.6.5", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", + "@vitest/browser-playwright": "5.0.0", "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", "happy-dom": "^20.8.9", + "msw": "2.15.0", + "playwright": "1.62.1", "qrcode.react": "4.2.0", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts new file mode 100644 index 00000000000..514aedbfc20 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -0,0 +1,186 @@ +import type { + ClientJSON, + OrganizationMembershipJSON, + OrganizationSuggestionJSON, + SessionJSON, + UserOrganizationInvitationJSON, +} from '@clerk/shared/types'; +import { http, HttpResponse, type JsonBodyType } from 'msw'; +import { setupWorker } from 'msw/browser'; + +import { fapiClient, type FapiEnvironment, fapiEnvironment, fapiPage, fapiToken } from './fapi'; + +export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; +const FAPI = 'https://clerk.abcef.12345.prod.lclclerk.com'; + +export const fapiUrl = (path: string) => `${FAPI}${path}`; + +export interface FakeFapiState { + environment: FapiEnvironment; + client: ClientJSON; + memberships: OrganizationMembershipJSON[]; + invitations: UserOrganizationInvitationJSON[]; + suggestions: OrganizationSuggestionJSON[]; +} + +export type FakeFapiSeed = Partial; + +const unhandled: string[] = []; + +export const worker = setupWorker(); + +export function startWorker() { + return worker.start({ + quiet: true, + onUnhandledRequest: request => { + if (request.url.startsWith(FAPI)) { + unhandled.push(`${request.method} ${request.url}`); + } + }, + }); +} + +export function takeUnhandledRequests(): string[] { + return unhandled.splice(0); +} + +function envelope(response: JsonBodyType, client: ClientJSON | null) { + return HttpResponse.json({ response, client }); +} + +function page(items: T[], url: URL) { + const offset = Number(url.searchParams.get('offset') ?? 0); + const limit = Number(url.searchParams.get('limit') ?? items.length); + return fapiPage(items.slice(offset, offset + limit), items.length); +} + +function withStatus(items: T[], url: URL): T[] { + const statuses = url.searchParams.getAll('status').flatMap(status => status.split(',')); + return statuses.length ? items.filter(item => statuses.includes(item.status)) : items; +} + +function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined { + return state.client.sessions.find(session => session.id === id); +} + +function missing() { + return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); +} + +export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { + const state: FakeFapiState = { + environment: fapiEnvironment(), + client: fapiClient(), + memberships: [], + invitations: [], + suggestions: [], + ...seed, + }; + + worker.use( + http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), + http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), + http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { + const session = findSession(state, params.id); + return session + ? HttpResponse.json( + fapiToken({ sid: session.id, sub: session.user.id, org_id: session.last_active_organization_id }), + ) + : missing(); + }), + http.post(fapiUrl('/v1/client/sessions/:id/touch'), async ({ params, request }) => { + const session = findSession(state, params.id); + if (!session) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const organizationId = body.get('active_organization_id') || null; + const touched = { + ...session, + last_active_organization_id: organizationId, + last_active_token: fapiToken({ sid: session.id, sub: session.user.id, org_id: organizationId }), + }; + state.client = { + ...state.client, + last_active_session_id: touched.id, + sessions: state.client.sessions.map(s => (s.id === touched.id ? touched : s)), + }; + return envelope(touched, state.client); + }), + http.post(fapiUrl('/v1/client/sessions/:id/remove'), ({ params }) => { + const session = findSession(state, params.id); + if (!session) { + return missing(); + } + const sessions = state.client.sessions.filter(s => s.id !== session.id); + state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null }; + return envelope({ ...session, status: 'removed' }, state.client); + }), + http.post(fapiUrl('/v1/client/sessions'), ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + state.client = { ...state.client, sessions: [], last_active_session_id: null }; + return envelope(state.client, state.client); + }), + http.get(fapiUrl('/v1/me/organization_memberships'), ({ request }) => + envelope(page(state.memberships, new URL(request.url)), null), + ), + http.get(fapiUrl('/v1/me/organization_invitations'), ({ request }) => { + const url = new URL(request.url); + return envelope(page(withStatus(state.invitations, url), url), null); + }), + http.get(fapiUrl('/v1/me/organization_suggestions'), ({ request }) => { + const url = new URL(request.url); + return envelope(page(withStatus(state.suggestions, url), url), null); + }), + http.post(fapiUrl('/v1/me/organization_invitations/:id/accept'), ({ params }) => { + const invitation = state.invitations.find(i => i.id === params.id); + if (!invitation) { + return missing(); + } + const accepted = { ...invitation, status: 'accepted' as const }; + state.invitations = state.invitations.map(i => (i.id === accepted.id ? accepted : i)); + return envelope(accepted, state.client); + }), + http.post(fapiUrl('/v1/me/organization_suggestions/:id/accept'), ({ params }) => { + const suggestion = state.suggestions.find(s => s.id === params.id); + if (!suggestion) { + return missing(); + } + const accepted = { ...suggestion, status: 'accepted' as const }; + state.suggestions = state.suggestions.map(s => (s.id === accepted.id ? accepted : s)); + return envelope(accepted, state.client); + }), + ); + + return state; +} + +export interface HeldRequests { + requests: Request[]; + release: () => void; + fail: (code?: string) => void; +} + +export function holdRequests(method: 'get' | 'post', path: string): HeldRequests { + const requests: Request[] = []; + let settle: (response: Response | undefined) => void = () => {}; + const gate = new Promise(resolve => { + settle = resolve; + }); + + worker.use( + http[method](fapiUrl(path), ({ request }) => { + requests.push(request.clone()); + return gate; + }), + ); + + return { + requests, + release: () => settle(undefined), + fail: (code = 'form_param_invalid') => + settle(HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status: 400 })), + }; +} diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts new file mode 100644 index 00000000000..1473d6e67c6 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -0,0 +1,394 @@ +import type { + AttributeDataJSON, + AttributesJSON, + AuthConfigJSON, + ClientJSON, + DisplayConfigJSON, + EmailAddressJSON, + EnvironmentJSON, + OAuthProviders, + OrganizationJSON, + OrganizationMembershipJSON, + OrganizationSettingsJSON, + OrganizationSuggestionJSON, + PublicOrganizationDataJSON, + SessionJSON, + TokenJSON, + UserJSON, + UserOrganizationInvitationJSON, + UserSettingsJSON, +} from '@clerk/shared/types'; + +type Settings = Omit; + +export type FapiUserSettings = Omit, 'social'> & { social: Partial }; + +export type FapiEnvironment = Omit & { + user_settings: FapiUserSettings; + organization_settings: Settings; +}; + +export interface FapiEnvironmentOverrides { + auth_config?: Partial; + display_config?: Partial; + organization_settings?: Partial>; + user_settings?: Partial; +} + +export interface FapiPage { + data: T[]; + total_count: number; +} + +const createdAt = Date.UTC(2026, 0, 1); +const farFuture = Date.UTC(2100, 0, 1); + +function base64Url(value: object): string { + return btoa(JSON.stringify(value)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export function fapiToken(claims: Record = {}): TokenJSON { + const iat = Math.floor(Date.now() / 1000); + return { + object: 'token', + id: '', + jwt: [ + base64Url({ alg: 'RS256', typ: 'JWT', kid: 'ins_test' }), + base64Url({ iat, nbf: iat, exp: iat + 3600, ...claims }), + 'signature', + ].join('.'), + }; +} + +function attribute(overrides: Partial = {}): AttributeDataJSON { + return { + enabled: true, + required: false, + verifications: [], + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verify_at_sign_up: false, + ...overrides, + }; +} + +function attributes(): AttributesJSON { + return { + email_address: attribute({ + verifications: ['email_code'], + used_for_first_factor: true, + first_factors: ['email_code'], + verify_at_sign_up: true, + }), + phone_number: attribute({ enabled: false }), + username: attribute(), + first_name: attribute(), + last_name: attribute(), + password: attribute(), + web3_wallet: attribute({ enabled: false }), + authenticator_app: attribute({ enabled: false }), + backup_code: attribute({ enabled: false }), + passkey: attribute({ enabled: false }), + }; +} + +export function fapiEnvironment(overrides: FapiEnvironmentOverrides = {}): FapiEnvironment { + return { + object: 'environment', + id: 'env_1', + maintenance_mode: false, + api_keys_settings: { + object: 'api_keys_settings', + id: 'api_keys_settings_1', + user_api_keys_enabled: false, + orgs_api_keys_enabled: false, + }, + auth_config: { + object: 'auth_config', + id: 'aac_1', + single_session_mode: false, + claimed_at: null, + reverification: false, + ...overrides.auth_config, + }, + commerce_settings: { + object: 'commerce_settings', + id: 'commerce_settings_1', + billing: { + stripe_publishable_key: null, + organization: { enabled: false, has_paid_plans: false }, + user: { enabled: false, has_paid_plans: false }, + }, + }, + display_config: { + object: 'display_config', + id: 'display_config_1', + after_sign_in_url: '', + after_sign_out_all_url: '/after-sign-out', + after_sign_out_one_url: '/after-single-sign-out', + after_sign_up_url: '', + after_switch_session_url: '', + application_name: 'Acme', + branded: false, + captcha_public_key: null, + captcha_widget_type: null, + captcha_public_key_invisible: null, + captcha_provider: 'turnstile', + captcha_oauth_bypass: null, + home_url: '', + instance_environment_type: 'production', + logo_image_url: '', + favicon_image_url: '', + preferred_sign_in_strategy: 'password', + sign_in_url: '/sign-in', + sign_up_url: '/sign-up', + support_email: '', + theme: { + general: { + color: '#6c47ff', + background_color: '#ffffff', + font_family: '', + font_color: '#000000', + label_font_weight: '600', + padding: '1em', + border_radius: '0.5em', + box_shadow: 'none', + }, + buttons: { font_color: '#ffffff', font_family: '', font_weight: '600' }, + accounts: { background_color: '#ffffff' }, + }, + user_profile_url: '/user-profile', + organization_profile_url: '/organization-profile', + create_organization_url: '/create-organization', + after_leave_organization_url: '', + after_create_organization_url: '', + show_devmode_warning: false, + terms_url: '', + privacy_policy_url: '', + waitlist_url: '', + after_join_waitlist_url: '', + ...overrides.display_config, + }, + organization_settings: { + enabled: true, + max_allowed_memberships: 5, + force_organization_selection: false, + actions: { admin_delete: true }, + domains: { enabled: false, enrollment_modes: [], default_role: null }, + slug: { disabled: false }, + organization_creation_defaults: { enabled: false }, + ...overrides.organization_settings, + }, + user_settings: { + attributes: attributes(), + actions: { delete_self: true, create_organization: true }, + social: {}, + enterprise_sso: { enabled: false, self_serve_sso: false, self_serve_directory_sync: false }, + sign_in: { second_factor: { required: false, enabled: false } }, + sign_up: { + allowlist_only: false, + progressive: true, + captcha_enabled: false, + mode: 'public', + legal_consent_enabled: false, + }, + password_settings: { + allowed_special_characters: '', + disable_hibp: false, + min_length: 8, + max_length: 72, + require_special_char: false, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + }, + passkey_settings: { allow_autofill: false, show_sign_in_button: false }, + username_settings: { min_length: 4, max_length: 64 }, + ...overrides.user_settings, + }, + protect_config: { object: 'protect_config', id: 'protect_config_1' }, + }; +} + +export function fapiEmailAddress( + overrides: Partial & Pick, +): EmailAddressJSON { + return { + object: 'email_address', + email_address: `${overrides.id}@example.com`, + verification: null, + linked_to: [], + matches_sso_connection: false, + ...overrides, + }; +} + +export function fapiUser(overrides: Partial & Pick): UserJSON { + return { + object: 'user', + external_id: null, + primary_email_address_id: overrides.email_addresses?.[0]?.id ?? null, + primary_phone_number_id: null, + primary_web3_wallet_id: null, + image_url: '', + has_image: false, + username: null, + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + enterprise_accounts: [], + passkeys: [], + organization_memberships: [], + password_enabled: true, + profile_image_id: '', + first_name: null, + last_name: null, + totp_enabled: false, + backup_code_enabled: false, + two_factor_enabled: false, + public_metadata: {}, + unsafe_metadata: {}, + last_sign_in_at: null, + create_organization_enabled: true, + create_organizations_limit: null, + delete_self_enabled: true, + legal_accepted_at: null, + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +export function fapiSession(overrides: Partial & Pick): SessionJSON { + const { user } = overrides; + const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); + return { + object: 'session', + status: 'active', + factor_verification_age: null, + expire_at: farFuture, + abandon_at: farFuture, + last_active_at: createdAt, + last_active_token: fapiToken({ sid: overrides.id, sub: user.id }), + last_active_organization_id: null, + actor: null, + tasks: null, + public_user_data: { + first_name: user.first_name, + last_name: user.last_name, + image_url: user.image_url, + has_image: user.has_image, + identifier: user.username ?? primaryEmail?.email_address ?? '', + user_id: user.id, + }, + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +export function fapiClient(sessions: SessionJSON[] = []): ClientJSON { + return { + object: 'client', + id: 'client_1', + sessions, + sign_in: null, + sign_up: null, + last_active_session_id: sessions[0]?.id ?? null, + last_authentication_strategy: null, + cookie_expires_at: null, + created_at: createdAt, + updated_at: createdAt, + }; +} + +export function fapiOrganization( + overrides: Partial & Pick, +): OrganizationJSON { + return { + object: 'organization', + image_url: '', + has_image: false, + slug: overrides.id, + public_metadata: {}, + members_count: 1, + pending_invitations_count: 0, + admin_delete_enabled: true, + max_allowed_memberships: 5, + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +export function fapiMembership( + organization: OrganizationJSON, + overrides: Partial = {}, +): OrganizationMembershipJSON { + return { + object: 'organization_membership', + id: `orgmem_${organization.id}`, + organization, + permissions: [], + public_metadata: {}, + role: 'org:member', + role_name: 'Member', + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +function publicOrganizationData(organization: OrganizationJSON): PublicOrganizationDataJSON { + return { + id: organization.id, + name: organization.name, + slug: organization.slug, + has_image: organization.has_image, + image_url: organization.image_url, + }; +} + +export function fapiInvitation( + id: string, + organization: OrganizationJSON, + overrides: Partial = {}, +): UserOrganizationInvitationJSON { + return { + object: 'organization_invitation', + id, + email_address: '', + public_organization_data: publicOrganizationData(organization), + public_metadata: {}, + status: 'pending', + role: 'org:member', + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +export function fapiSuggestion( + id: string, + organization: OrganizationJSON, + overrides: Partial = {}, +): OrganizationSuggestionJSON { + return { + object: 'organization_suggestion', + id, + public_organization_data: publicOrganizationData(organization), + status: 'pending', + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + +export function fapiPage(data: T[], totalCount = data.length): FapiPage { + return { data, total_count: totalCount }; +} diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx new file mode 100644 index 00000000000..3558a2df6f5 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -0,0 +1,36 @@ +import { Clerk } from '@clerk/clerk-js'; +import { ClerkContextProvider } from '@clerk/shared/react'; +import { act, render } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { PUBLISHABLE_KEY } from './fake-fapi'; + +export async function renderWithClerk(ui: ReactElement) { + const clerk = new Clerk(PUBLISHABLE_KEY); + const navigate = vi.fn((_to: string) => Promise.resolve()); + + const wrap = (element: ReactElement) => ( + + {element} + + ); + let current = ui; + const view = render(wrap(current)); + await act(() => clerk.load({ routerPush: to => navigate(to), routerReplace: to => navigate(to) })); + view.rerender(wrap(current)); + + return { + ...view, + clerk, + navigate, + rerender: (element: ReactElement) => { + current = element; + view.rerender(wrap(current)); + }, + }; +} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx new file mode 100644 index 00000000000..0f4039f2ef7 --- /dev/null +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -0,0 +1,595 @@ +import type { CustomPage } from '@clerk/shared/types'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiInvitation, + fapiMembership, + fapiOrganization, + fapiSession, + fapiSuggestion, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import type { UserButtonProps } from '../user-button'; +import { UserButton } from '../user-button'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); +const other = fapiOrganization({ id: 'org_9', name: 'Other' }); +const beta = fapiOrganization({ id: 'org_2', name: 'Beta' }); +const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma' }); + +const aliceMemberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(other)]; + +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + username: 'alice', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: aliceMemberships, +}); + +const bob = fapiUser({ + id: 'user_2', + first_name: 'Bob', + last_name: 'Jones', + email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })], +}); + +const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' }); +const bobSession = fapiSession({ id: 'sess_2', user: bob }); + +function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { + return { + client: fapiClient([aliceSession, bobSession]), + memberships: aliceMemberships, + invitations: [fapiInvitation('inv_1', gamma)], + suggestions: [fapiSuggestion('sug_1', beta)], + ...overrides, + }; +} + +function tree(props: UserButtonProps = {}) { + return ( +
+ +
+ ); +} + +function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { + const fapi = serveFapi(seed); + return renderWithClerk(tree(props)).then(view => ({ ...view, fapi })); +} + +const host = () => screen.getByTestId('host'); +const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const popup = () => screen.queryByRole('dialog', { name: 'Account' }); +const spinner = () => popup()?.querySelector('.cl-spinner') ?? null; +const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' }); + +async function open() { + const user = userEvent.setup(); + await user.click(trigger()); + expect(popup()).toBeInTheDocument(); + return user; +} + +async function accountAction(user: ReturnType, label: string) { + await user.click(accountMenu()); + await user.click(await screen.findByRole('menuitem', { name: label })); +} + +async function openAccounts(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Switch account' })); + return screen.findByRole('menu'); +} + +function requiredPopup() { + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + return surface; +} + +describe('UserButton', () => { + describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => { + it('renders nothing while Clerk is still loading', async () => { + serveFapi(signedIn()); + const client = holdRequests('get', '/v1/client'); + const rendering = renderWithClerk(tree({ mode })); + + await waitFor(() => expect(client.requests).toHaveLength(1)); + expect(host()).toBeEmptyDOMElement(); + + client.release(); + await rendering; + expect(trigger()).toBeInTheDocument(); + }); + + it('renders nothing when nobody is signed in', async () => { + await renderUserButton({ mode }, signedIn({ client: fapiClient() })); + + expect(host()).toBeEmptyDOMElement(); + }); + + it('leaves organizations out entirely when the instance has them disabled', async () => { + await renderUserButton( + { mode }, + signedIn({ environment: fapiEnvironment({ organization_settings: { enabled: false } }) }), + ); + + expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument(); + const user = await open(); + + for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) { + expect(screen.queryByText(name)).toBeNull(); + } + expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + const menu = await openAccounts(user); + expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); + }); + }); + + it('keeps the popover closed until the trigger is clicked', async () => { + await renderUserButton(); + + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('lists the organizations and the account when opened', async () => { + await renderUserButton(); + await open(); + + expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(accountMenu()).toBeInTheDocument(); + }); + + describe('switching workspace', () => { + it('makes the selected organization active and closes', async () => { + const { navigate } = await renderUserButton(); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Other' })); + await waitFor(() => expect(popup()).toBeNull()); + + await open(); + expect(await screen.findByRole('button', { name: 'Acme' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Other' })).toBeNull(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('leaves the active organization for the personal workspace', async () => { + await renderUserButton(); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Personal account' })); + await waitFor(() => expect(popup()).toBeNull()); + + await open(); + expect(await screen.findByRole('button', { name: 'Acme' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull(); + }); + + it('drops the personal workspace where the app hides it', async () => { + await renderUserButton({ hidePersonal: true }); + await open(); + + expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(screen.queryByText('Personal account')).toBeNull(); + }); + + it('names no organization selected when the instance forces one and none is active', async () => { + await renderUserButton( + {}, + signedIn({ + environment: fapiEnvironment({ organization_settings: { force_organization_selection: true } }), + client: fapiClient([fapiSession({ id: 'sess_1', user: alice }), bobSession]), + }), + ); + + expect(screen.getByRole('button', { name: /No organization selected/ })).toBeInTheDocument(); + await open(); + + expect(within(requiredPopup()).getByText('No organization selected')).toBeInTheDocument(); + expect(screen.queryByText('Personal account')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + }); + + it('lists no organizations on an account-only surface', async () => { + await renderUserButton({ mode: 'user' }); + const user = await open(); + + const menu = await openAccounts(user); + expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Other' })).toBeNull(); + }); + }); + + describe('accounts', () => { + it('switches to another signed-in account and stays open', async () => { + await renderUserButton(); + const user = await open(); + + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); + + await waitFor(() => expect(trigger()).toHaveAccessibleName('Open account menu for Bob Jones')); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('signs out of the active account alone while another stays signed in', async () => { + const { fapi, navigate } = await renderUserButton(); + const user = await open(); + + await accountAction(user, 'Sign out'); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/after-single-sign-out')); + expect(fapi.client.sessions.map(session => session.id)).toEqual(['sess_2']); + }); + + it('does not reopen after signing out of the last account and signing back in', async () => { + const { clerk, fapi, navigate } = await renderUserButton({}, signedIn({ client: fapiClient([aliceSession]) })); + const user = await open(); + + await accountAction(user, 'Sign out'); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/')); + await waitFor(() => expect(host()).toBeEmptyDOMElement()); + + fapi.client = fapiClient([aliceSession]); + await act(async () => { + await clerk.client?.reload(); + await clerk.setActive({ session: 'sess_1' }); + }); + + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('does not reopen after signing out of all accounts and signing back in', async () => { + const { clerk, fapi, navigate } = await renderUserButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/')); + await waitFor(() => expect(host()).toBeEmptyDOMElement()); + + fapi.client = fapiClient([aliceSession]); + await act(async () => { + await clerk.client?.reload(); + await clerk.setActive({ session: 'sess_1' }); + }); + + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('drops add-account and sign-out-of-all in single-session mode', async () => { + await renderUserButton( + {}, + signedIn({ + environment: fapiEnvironment({ auth_config: { single_session_mode: true } }), + client: fapiClient([aliceSession]), + }), + ); + const user = await open(); + + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + expect(screen.queryByLabelText('Account actions')).toBeNull(); + await user.click(accountMenu()); + expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull(); + }); + }); + + describe('invitations and suggestions', () => { + it('accepts an invitation and stays open', async () => { + await renderUserButton(); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Accept' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('stays busy until the invitations have reloaded', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Accept' }); + const reload = holdRequests('get', '/v1/me/organization_invitations'); + + await user.click(screen.getByRole('button', { name: 'Accept' })); + await waitFor(() => expect(reload.requests).toHaveLength(1)); + + await new Promise(resolve => setTimeout(resolve, 450)); + expect(spinner()).toBeInTheDocument(); + + reload.release(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('joins a suggested organization and stays open', async () => { + await renderUserButton(); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Join' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Join' })).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('spins inside the join button while the suggestion is joined', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Join' }); + const join = holdRequests('post', '/v1/me/organization_suggestions/:id/accept'); + + await user.click(screen.getByRole('button', { name: 'Join' })); + + const button = screen.getByRole('button', { name: 'Join' }); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(within(button).getByRole('progressbar')).toBeInTheDocument(); + + join.release(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + }); + + describe('handing off', () => { + it('opens the UserProfile modal and closes when managing the account', async () => { + const { clerk, navigate } = await renderUserButton(); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Manage account'); + + expect(openUserProfile).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('runs a custom menu item and closes', async () => { + const onClick = vi.fn(); + await renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] }); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Terms of service' })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('renders a custom page into the element the opened profile hands back', async () => { + const { clerk } = await renderUserButton({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages).toHaveLength(1); + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => { + customPages[0]?.mount?.(el); + }); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('opens the profile with its pages in the order it was given', async () => { + const { clerk } = await renderUserButton({ + userProfileProps: { + customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], + pageOrder: ['account', 'terms'], + }, + }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + }); + + it('opens the InviteMembers modal and closes', async () => { + const { clerk, navigate } = await renderUserButton(); + const openInviteMembers = vi.spyOn(clerk, 'openInviteMembers').mockImplementation(() => {}); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Invite' })); + + expect(openInviteMembers).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('opens the CreateOrganization modal and closes', async () => { + const { clerk, navigate } = await renderUserButton(); + const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Create organization'); + + expect(openCreateOrganization).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('navigates to create an organization when a URL routes it', async () => { + const { clerk, navigate } = await renderUserButton({ createOrganizationUrl: '/new-org' }); + const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Create organization'); + + expect(navigate).toHaveBeenCalledWith('/new-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('leaves create-organization out for a user who cannot create one', async () => { + const restricted = fapiUser({ ...alice, create_organization_enabled: false }); + await renderUserButton( + {}, + signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: restricted }), bobSession]) }), + ); + const user = await open(); + await user.click(accountMenu()); + + expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull(); + }); + }); + + describe('while an action is in flight', () => { + it('spins the clicked row and stands every other one down', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Other' }); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await user.click(screen.getByRole('button', { name: 'Other' })); + + expect(spinner()).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Switch account' })).toBeDisabled(); + expect(popup()).toBeInTheDocument(); + + touch.release(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('holds the surface on the organization it started with while Clerk navigates after a switch', async () => { + const { navigate } = await renderUserButton({ + afterSelectOrganizationUrl: '/org/:id', + fallback: Loading, + }); + let arrive: () => void = () => {}; + navigate.mockImplementationOnce( + () => + new Promise(resolve => { + arrive = resolve; + }), + ); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Other' })); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/org/org_9')); + + expect(screen.queryByTestId('fallback')).toBeNull(); + expect(within(requiredPopup()).getAllByText('Acme')).toHaveLength(2); + + act(() => arrive()); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('stays open and clears busy state when the switch fails', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Other' }); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await user.click(screen.getByRole('button', { name: 'Other' })); + expect(spinner()).toBeInTheDocument(); + + touch.fail(); + + await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 }); + expect(popup()).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + }); + + it('still shows the action when reopened before it settles, and starts no second one', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Other' }); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); + expect(spinner()).toBeInTheDocument(); + + await user.click(trigger()); + await waitFor(() => expect(popup()).toBeNull()); + + await new Promise(resolve => setTimeout(resolve, 450)); + await user.click(trigger()); + + expect(popup()).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Switch account' }).querySelector('.cl-spinner')).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Other' })); + expect(touch.requests).toHaveLength(1); + + touch.release(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('closes on success even if the popover was dismissed and reopened meanwhile', async () => { + await renderUserButton(); + const user = await open(); + await screen.findByRole('button', { name: 'Other' }); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await user.click(screen.getByRole('button', { name: 'Other' })); + await user.click(trigger()); + await waitFor(() => expect(popup()).toBeNull()); + + await user.click(trigger()); + expect(spinner()).toBeInTheDocument(); + + touch.release(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('reopens ready to use after an action that closed it', async () => { + await renderUserButton(); + const user = await open(); + + await user.click(await screen.findByRole('button', { name: 'Other' })); + await waitFor(() => expect(popup()).toBeNull()); + + await user.click(trigger()); + + expect(spinner()).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + }); + }); + + it('loads the next page of organizations when the end of the list scrolls into view', async () => { + const many = Array.from({ length: 15 }, (_, i) => fapiOrganization({ id: `org_p${i + 1}`, name: `Org ${i + 1}` })); + await renderUserButton({}, signedIn({ memberships: [fapiMembership(acme), ...many.map(o => fapiMembership(o))] })); + await open(); + + const last = await screen.findByRole('button', { name: 'Org 9' }); + last.scrollIntoView(); + + expect(await screen.findByRole('button', { name: 'Org 15' })).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx deleted file mode 100644 index 119231aefdc..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx +++ /dev/null @@ -1,773 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import type { CustomPage } from '@clerk/shared/types'; -import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserButtonProps } from '../user-button'; -import { UserButton } from '../user-button'; - -// End-to-end wiring test for the connected UserButton: it renders the real view through the real -// model and controller against a mocked Clerk, then drives the real popover DOM. Unlike the model -// test (model -> Clerk), this proves the layers compose, including what closes the popover: -// selecting a workspace closes on success in the machine, and anything that opens a modal or -// navigates closes before it hands off. - -interface FakeUser { - id: string; - firstName: string | null; - lastName: string | null; - username: string | null; - primaryEmailAddress: { emailAddress: string } | null; - imageUrl: string; - organizationMemberships: unknown[]; - createOrganizationEnabled: boolean; -} - -interface FakeSession { - id: string; - user: FakeUser; -} - -interface FakeList { - data: unknown[]; - count: number; - hasNextPage: boolean; - isLoading: boolean; - revalidate: ReturnType; -} - -let isUserLoaded: boolean; -let isSessionLoaded: boolean; -let isOrgLoaded: boolean; -let user: FakeUser | null; -let session: { id: string; checkAuthorization: ReturnType } | null; -let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; -let userMemberships: FakeList; -let userInvitations: FakeList; -let userSuggestions: FakeList; -let signedInSessions: FakeSession[]; -let pagingRef: ReturnType; -let singleSessionMode: boolean; -let organizationsEnabled: boolean; -let forceOrganizationSelection: boolean; - -let setActive: ReturnType; -let signOut: ReturnType; -let navigate: ReturnType; -let openUserProfile: ReturnType; -let openOrganizationProfile: ReturnType; -let openCreateOrganization: ReturnType; -let openInviteMembers: ReturnType; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), - useClerk: () => ({ - navigate, - setActive, - signOut, - openUserProfile, - openOrganizationProfile, - openCreateOrganization, - openInviteMembers, - buildUserProfileUrl: () => '/user-profile', - buildOrganizationProfileUrl: () => '/org-profile', - buildCreateOrganizationUrl: () => '/create-org', - buildSignInUrl: () => '/sign-in', - buildAfterSignOutUrl: () => '/after-sign-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', - client: { signedInSessions }, - __internal_environment: { - displayConfig: { afterSwitchSessionUrl: '/after-switch' }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } }, - apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false }, - userSettings: { enterpriseSSO: { self_serve_sso: false } }, - }, - }), - }; -}); - -// Stubbed at the same seam as the controller test: the in-view helper is the controller's whole -// fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted. - -vi.mock('../../../hooks/useOrganizationListInView', () => ({ - useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), -})); - -function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') { - return { - id, - status, - accept: vi.fn().mockResolvedValue(undefined), - publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, - }; -} - -function membership(orgId: string, name: string, membersCount: number) { - return { organization: { id: orgId, name, imageUrl: '', membersCount } }; -} - -function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { - return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; -} - -/** A promise whose settling is controlled by the test, to hold an async action in flight. */ -function createDeferred() { - let resolve: () => void = () => {}; - let reject: (reason?: unknown) => void = () => {}; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -beforeEach(() => { - isUserLoaded = true; - isSessionLoaded = true; - isOrgLoaded = true; - user = { - id: 'user_1', - firstName: 'Alice', - lastName: 'Smith', - username: 'alice', - primaryEmailAddress: { emailAddress: 'alice@example.com' }, - imageUrl: 'https://img/alice', - organizationMemberships: [{ id: 'orgmem_1' }], - createOrganizationEnabled: true, - }; - session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) }; - organization = { id: 'org_1', name: 'Acme', imageUrl: '', membersCount: 3 }; - userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); - userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); - userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); - pagingRef = vi.fn(); - singleSessionMode = false; - organizationsEnabled = true; - forceOrganizationSelection = false; - signedInSessions = [ - { id: 'sess_1', user }, - { - id: 'sess_2', - user: { - id: 'user_2', - firstName: 'Bob', - lastName: 'Jones', - username: null, - primaryEmailAddress: { emailAddress: 'bob@example.com' }, - imageUrl: 'https://img/bob', - organizationMemberships: [], - createOrganizationEnabled: true, - }, - }, - ]; - setActive = vi.fn().mockResolvedValue(undefined); - signOut = vi.fn().mockResolvedValue(undefined); - navigate = vi.fn().mockResolvedValue(undefined); - openUserProfile = vi.fn(); - openOrganizationProfile = vi.fn(); - openCreateOrganization = vi.fn(); - openInviteMembers = vi.fn(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -function tree(props: UserButtonProps = {}) { - return ( - - {/* The button portals its popup out, so this host holds only what it renders in place. */} -
- -
-
- ); -} - -function renderUserButton(props: UserButtonProps = {}) { - return render(tree(props)); -} - -/** Sign-out unmounts the button (`hidden`). If the machine stayed `open`, the next ready render would show the menu. */ -function signedOutThenIn(rerender: (ui: React.ReactElement) => void, props: UserButtonProps = {}) { - const signedInUser = user; - const signedInSession = session; - user = null; - session = null; - rerender(tree(props)); - user = signedInUser; - session = signedInSession; - rerender(tree(props)); -} - -const host = () => screen.getByTestId('host'); -const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); -const popup = () => screen.queryByRole('dialog', { name: 'Account' }); -const spinner = () => popup()?.querySelector('.cl-spinner') ?? null; - -async function open() { - const act = userEvent.setup(); - await act.click(trigger()); - expect(popup()).toBeInTheDocument(); - return act; -} - -// Alice has a username, so that is what identifies her row; Bob has none and falls back to email. -const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' }); - -/** Opens the `⋯` on the active account's row and clicks one of its actions. */ -async function accountAction(act: ReturnType, label: string) { - await act.click(accountMenu()); - await act.click(await screen.findByRole('menuitem', { name: label })); -} - -/** Opens the accounts flyout at the foot, and hands back the menu it opens. */ -async function openAccounts(act: ReturnType) { - await act.click(screen.getByRole('button', { name: 'Switch account' })); - return screen.findByRole('menu'); -} - -describe('UserButton (connected)', () => { - // Nothing stands in for the button before Clerk answers, in any mode: until it does, a signed-out - // visitor is indistinguishable from a session still resolving, so a placeholder here would be - // promising a button to people who never get one. - describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => { - it('renders nothing while Clerk is still loading', () => { - isUserLoaded = false; - renderUserButton({ mode }); - expect(host()).toBeEmptyDOMElement(); - }); - - it('renders nothing when nobody is signed in', () => { - user = null; - renderUserButton({ mode }); - expect(host()).toBeEmptyDOMElement(); - }); - - // Organizations off at the instance is the same answer whatever mode asked for: the button is - // the account's. An org-only surface would otherwise render its own empty shell, since the - // clerk-js mount boundary that withholds `` never runs for this one. - it('leaves organizations out entirely when the instance has them disabled', async () => { - organizationsEnabled = false; - renderUserButton({ mode }); - - // The account heads the surface, rather than the organization that is active regardless. - expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument(); - const act = await open(); - - for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) { - expect(screen.queryByText(name)).toBeNull(); - } - expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); - - // Everything the account itself carries is still on offer. - expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); - const menu = await openAccounts(act); - expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); - }); - }); - - it('renders the fallback while Clerk is still loading', () => { - isUserLoaded = false; - renderUserButton({ fallback: }); - expect(screen.getByTestId('fallback')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Open account menu/ })).toBeNull(); - }); - - it('drops the fallback once nobody is signed in', () => { - const props = { fallback: }; - isUserLoaded = false; - const { rerender } = renderUserButton(props); - expect(screen.getByTestId('fallback')).toBeInTheDocument(); - - isUserLoaded = true; - user = null; - rerender(tree(props)); - expect(host()).toBeEmptyDOMElement(); - }); - - it('renders the trigger and keeps the popover closed until clicked', () => { - renderUserButton(); - expect(trigger()).toBeInTheDocument(); - expect(popup()).toBeNull(); - }); - - it('opens the popover on trigger click', async () => { - renderUserButton(); - await open(); - - expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); - expect(accountMenu()).toBeInTheDocument(); - }); - - it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => { - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('leaving the active organization for the personal workspace clears it', async () => { - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Personal account' })); - - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('drops the personal workspace where the app hides it, leaving the organizations', async () => { - renderUserButton({ hidePersonal: true }); - await open(); - - expect(screen.queryByText('Personal account')).toBeNull(); - expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); - }); - - it('names no organization selected when the instance forces one and none is active', async () => { - forceOrganizationSelection = true; - organization = null; - renderUserButton(); - - expect(screen.getByRole('button', { name: /No organization selected/ })).toBeInTheDocument(); - await open(); - - const surface = popup(); - if (!surface) { - throw new Error('expected the popover to be open'); - } - expect(within(surface).getByText('No organization selected')).toBeInTheDocument(); - expect(screen.queryByText('Personal account')).toBeNull(); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); - }); - - // `mode` is the view's own prop; this only proves the connected component hands it down, since - // the account-only surface is otherwise indistinguishable from an account with no organizations. - it('forwards mode to the view, so an account-only surface lists no organizations', async () => { - renderUserButton({ mode: 'user' }); - const act = await open(); - - expect(screen.queryByRole('button', { name: 'Other' })).toBeNull(); - const menu = await openAccounts(act); - expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); - }); - - it('switching to another account calls setActive with the session and stays open', async () => { - renderUserButton(); - const act = await open(); - - const menu = await openAccounts(act); - await act.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); - - expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('signing out of the active account calls signOut with its session id and stays open', async () => { - renderUserButton(); - const act = await open(); - - await accountAction(act, 'Sign out'); - - // Another account stays signed in, so this is a single sign out, not a full one. - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-single-sign-out' }); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('does not reopen after signing out of the last account and signing back in', async () => { - signedInSessions = signedInSessions.slice(0, 1); - const { rerender } = renderUserButton(); - const act = await open(); - - await accountAction(act, 'Sign out'); - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-sign-out' }); - - signedOutThenIn(rerender); - expect(trigger()).toBeInTheDocument(); - expect(popup()).toBeNull(); - }); - - it('does not reopen after signing out of all accounts and signing back in', async () => { - const { rerender } = renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); - expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); - - signedOutThenIn(rerender); - expect(trigger()).toBeInTheDocument(); - expect(popup()).toBeNull(); - }); - - it('accepting an invitation accepts it, revalidates, and stays open', async () => { - renderUserButton(); - const act = await open(); - const invitation = userInvitations.data[0] as ReturnType; - - await act.click(screen.getByRole('button', { name: 'Accept' })); - - await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1)); - expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('stays busy until the invitation lists have revalidated', async () => { - const deferred = createDeferred(); - userInvitations.revalidate.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Accept' })); - await waitFor(() => expect(userInvitations.revalidate).toHaveBeenCalledTimes(1)); - - // Longer than the spinner's minDuration, so a fire-and-forget refresh would have cleared it. - await new Promise(resolve => setTimeout(resolve, 450)); - expect(spinner()).toBeInTheDocument(); - - deferred.resolve(); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('accepting a suggestion accepts it, revalidates, and stays open', async () => { - renderUserButton(); - const act = await open(); - const suggestion = userSuggestions.data[0] as ReturnType; - - await act.click(screen.getByRole('button', { name: 'Join' })); - - await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1)); - expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('drops add-account and sign-out-of-all in single-session mode', async () => { - singleSessionMode = true; - signedInSessions = signedInSessions.slice(0, 1); - renderUserButton(); - const act = await open(); - - expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); - expect(screen.queryByLabelText('Account actions')).toBeNull(); - await act.click(accountMenu()); - expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull(); - }); - - it('managing the account opens the UserProfile modal and closes the popover', async () => { - renderUserButton(); - const act = await open(); - - await accountAction(act, 'Manage account'); - - expect(openUserProfile).toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - // A custom action is the app's to run, and whatever it opens takes over from here, so the popover - // goes with it the way it does for managing an account. - it('running a custom menu item calls back and closes the popover', async () => { - const onClick = vi.fn(); - renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] }); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Terms of service' })); - - expect(onClick).toHaveBeenCalledTimes(1); - await waitFor(() => expect(popup()).toBeNull()); - }); - - // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to - // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The - // popover has closed by then, so this also covers the portals outliving what opened them. - it('renders a custom page into the element the opened profile hands back', async () => { - renderUserButton({ - userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, - }); - const act = await open(); - - await accountAction(act, 'Manage account'); - await waitFor(() => expect(popup()).toBeNull()); - - const { customPages } = openUserProfile.mock.calls[0][0]; - expect(customPages).toHaveLength(1); - expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); - - // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes. - const el = document.createElement('div'); - document.body.appendChild(el); - reactAct(() => { - customPages[0].mount(el); - }); - - expect(within(el).getByText('Terms body')).toBeInTheDocument(); - }); - - it('opens the profile with its pages in the order it was given', async () => { - renderUserButton({ - userProfileProps: { - customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], - pageOrder: ['account', 'terms'], - }, - }); - const act = await open(); - - await accountAction(act, 'Manage account'); - await waitFor(() => expect(popup()).toBeNull()); - - const { customPages } = openUserProfile.mock.calls[0][0]; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); - }); - - it('inviting members opens the InviteMembers modal and closes the popover', async () => { - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Invite' })); - - expect(openInviteMembers).toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('creating an organization opens the modal and closes the popover', async () => { - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Create organization' })); - - expect(openCreateOrganization).toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('creating an organization navigates instead when a URL routes it', async () => { - renderUserButton({ createOrganizationUrl: '/new-org' }); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Create organization' })); - - expect(navigate).toHaveBeenCalledWith('/new-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('leaves "Create organization" out of the list for a user who cannot open one', async () => { - user = { ...(user as FakeUser), createOrganizationEnabled: false }; - renderUserButton(); - await open(); - - expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); - }); - - it('spins the clicked affordance and stands every other one down while an action is in flight', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - - // Every one of these is a network round trip, so there is nothing to debounce: the click gets - // its spinner in the same pass rather than after a delay window. - expect(spinner()).toBeInTheDocument(); - // A stood-down row stays a button, and `aria-disabled` rather than natively disabled so it - // keeps its place in the tab order. Dropping it to a static row would remount it, and with it - // the avatar it carries. - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true'); - expect(popup()).toBeInTheDocument(); - - deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - // `setActive` swaps the active organization mid-flight. See `frozenModel` in the machine. - it('holds the surface on the data it started with until the action settles', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - organization = { id: 'org_9', name: 'Other', imageUrl: '', membersCount: 1 }; - - // Any re-render now reads the swapped organization; the surface must not follow it. - await waitFor(() => expect(spinner()).toBeInTheDocument()); - const surface = popup(); - if (!surface) { - throw new Error('expected the popover to be open'); - } - // Still the organization the surface opened on: heading it and listed under it, unclickable. - expect(within(surface).getAllByText('Acme')).toHaveLength(2); - expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull(); - - deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - // `setActive` with a navigation puts Clerk in transitive state: hooks report `isLoaded: false` - // and the live model goes `loading`. The frozen model is the one the action started from, so - // the surface has to keep it rather than flashing the fallback - it('does not render the fallback when Clerk resources reverts to loading', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - const props = { fallback: Loading }; - const { rerender } = renderUserButton(props); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - await waitFor(() => expect(spinner()).toBeInTheDocument()); - - // Mock the transitive state - isUserLoaded = false; - isSessionLoaded = false; - isOrgLoaded = false; - rerender(tree(props)); - - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - const surface = popup(); - if (!surface) { - throw new Error('expected the popover to be open'); - } - expect(within(surface).getAllByText('Acme')).toHaveLength(2); - - deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - it('spins inside the join button while a suggestion is being joined', async () => { - const deferred = createDeferred(); - const suggestion = userSuggestions.data[0] as ReturnType; - suggestion.accept.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Join' })); - - const join = screen.getByRole('button', { name: 'Join' }); - expect(join).toHaveAttribute('aria-busy', 'true'); - expect(within(join).getByRole('progressbar')).toBeInTheDocument(); - - deferred.resolve(); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('keeps the popover open and clears busy state when an action rejects', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - expect(spinner()).toBeInTheDocument(); - - deferred.reject(new Error('setActive failed')); - - await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 }); - expect(popup()).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); - }); - - // Closing must not drop the invoke: reopen should find the same row still pending, and a second - // action must not start. Wait out the spinner hold so a stale minDuration cannot fake this. - it('still shows the in-flight action when the popover is reopened before it settles', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - const menu = await openAccounts(act); - await act.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); - expect(spinner()).toBeInTheDocument(); - - await act.click(trigger()); - expect(popup()).toBeNull(); - - await new Promise(resolve => setTimeout(resolve, 450)); - await act.click(trigger()); - - const switchAccount = screen.getByRole('button', { name: 'Switch account' }); - expect(popup()).toBeInTheDocument(); - expect(switchAccount.querySelector('.cl-spinner')).not.toBeNull(); - expect(spinner()).toBeInTheDocument(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - expect(setActive).toHaveBeenCalledTimes(1); - - deferred.resolve(); - await waitFor(() => expect(spinner()).toBeNull()); - expect(popup()).toBeInTheDocument(); - }); - - it('closes on success even if the popover was dismissed and reopened while the action ran', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - expect(spinner()).toBeInTheDocument(); - - await act.click(trigger()); - expect(popup()).toBeNull(); - - await act.click(trigger()); - expect(popup()).toBeInTheDocument(); - expect(spinner()).toBeInTheDocument(); - - deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); - }); - - // The spinner is held up for a minimum so it cannot flicker off. That hold is for a surface still - // on screen, so an action that closes the surface must not carry it: reopening inside the window - // would otherwise find the popup spinning over rows that are all stood down, for nothing. - it('reopens ready to use after an action that closed it', async () => { - const deferred = createDeferred(); - setActive.mockReturnValueOnce(deferred.promise); - renderUserButton(); - const act = await open(); - - await act.click(screen.getByRole('button', { name: 'Other' })); - expect(spinner()).toBeInTheDocument(); - - deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); - - await act.click(trigger()); - - expect(spinner()).toBeNull(); - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); - }); - - // The view decides whether to mount the sentinel at all; this is the wiring that carries the - // in-view ref from the paginated lists, through the controller, to it. - it('hands the paging sentinel to the in-view ref when a list has a next page', async () => { - userMemberships = list([membership('org_1', 'Acme', 3)], 1, true); - renderUserButton(); - await open(); - - expect(pagingRef).toHaveBeenCalledWith(expect.any(HTMLElement)); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts index 3935170e88a..a7f160048fa 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts @@ -4,10 +4,7 @@ import { space } from '../../tokens.stylex'; export const styles = stylex.create({ countdown: { - display: 'inline-block', fontVariantNumeric: 'tabular-nums', - textAlign: 'center', - minWidth: '2ch', }, contactValue: { gap: space['2'], diff --git a/packages/mosaic/test/public/mockServiceWorker.js b/packages/mosaic/test/public/mockServiceWorker.js new file mode 100644 index 00000000000..7eb7b443e5b --- /dev/null +++ b/packages/mosaic/test/public/mockServiceWorker.js @@ -0,0 +1,343 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0'; +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', function () { + self.skipWaiting(); +}); + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter(client => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers.get('content-type')?.toLowerCase().startsWith('text/event-stream'); + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter(client => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find(client => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map(value => value.trim()); + const filteredValues = values.filter(value => value !== 'msw/passthrough'); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = event => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index af2a8f16fcf..bbcd57f5afe 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -1,9 +1,11 @@ import stylex from '@stylexjs/unplugin/vite'; +import { playwright } from '@vitest/browser-playwright'; import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; const mosaicPath = resolve(import.meta.dirname, 'src'); +const featureTests = 'src/**/*.feature.test.tsx'; export default defineConfig({ plugins: [ @@ -22,9 +24,9 @@ export default defineConfig({ exclude: ['node_modules/**', 'dist/**'], testTimeout: 5000, // Primitives ran as their own package on happy-dom with no setup beyond matchers, so they - // never picked up clerk-js's shared jsdom mocks (including a requestAnimationFrame mock that - // changes floating-ui's focus-on-open timing). Keeping them on their own project here, instead - // of folding them into the root jsdom project, preserves that behavior post-move. + // never picked up the requestAnimationFrame mock in the Mosaic setup (it changes floating-ui's + // focus-on-open timing). Keeping them on their own project here, instead of folding them into + // the jsdom project, preserves that behavior post-move. projects: [ { extends: true, @@ -40,8 +42,33 @@ export default defineConfig({ test: { name: 'mosaic', include: ['**/*.test.?(c|m)[jt]s?(x)', '**/*.spec.?(c|m)[jt]s?(x)'], - exclude: ['src/primitives/**'], - setupFiles: ['../clerk-js/vitest.setup.mts'], + exclude: ['src/primitives/**', featureTests], + setupFiles: ['./vitest.setup.mts'], + }, + }, + { + extends: true, + publicDir: 'test/public', + optimizeDeps: { + include: [ + '@clerk/clerk-js', + '@testing-library/jest-dom/matchers', + '@testing-library/react', + '@testing-library/user-event', + 'msw', + 'msw/browser', + ], + }, + test: { + name: 'feature', + include: [featureTests], + setupFiles: ['./vitest.setup.browser.mts'], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, }, }, ], diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts new file mode 100644 index 00000000000..c874f106507 --- /dev/null +++ b/packages/mosaic/vitest.setup.browser.mts @@ -0,0 +1,27 @@ +import { __resetClerkQueryClientForTest } from '@clerk/shared/react'; +import * as matchers from '@testing-library/jest-dom/matchers'; +import { cleanup } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, expect } from 'vitest'; + +import { startWorker, takeUnhandledRequests, worker } from './src/__tests__/feature/fake-fapi'; + +expect.extend(matchers); + +beforeAll(async () => { + await startWorker(); +}); + +beforeEach(() => { + window.focus(); +}); + +afterEach(() => { + cleanup(); + __resetClerkQueryClientForTest(); + worker.resetHandlers(); + expect(takeUnhandledRequests(), 'Frontend API requests without a handler').toEqual([]); +}); + +afterAll(() => { + worker.stop(); +}); diff --git a/packages/mosaic/vitest.setup.mts b/packages/mosaic/vitest.setup.mts new file mode 100644 index 00000000000..e6347a76dd9 --- /dev/null +++ b/packages/mosaic/vitest.setup.mts @@ -0,0 +1,34 @@ +import '@testing-library/jest-dom/vitest'; + +import { cleanup, configure } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +configure({ asyncUtilTimeout: 5000 }); + +const frames = new Map>(); +let nextFrame = 0; + +if (typeof window !== 'undefined') { + window.requestAnimationFrame = callback => { + const handle = ++nextFrame; + frames.set( + handle, + setTimeout(() => { + frames.delete(handle); + callback(performance.now()); + }, 0), + ); + return handle; + }; + + window.cancelAnimationFrame = handle => { + clearTimeout(frames.get(handle)); + frames.delete(handle); + }; +} + +afterEach(() => { + cleanup(); + frames.forEach(timeout => clearTimeout(timeout)); + frames.clear(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2b32a7f6a..9f48dc0a4d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,7 +190,7 @@ importers: version: 4.7.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 5.0.0 - version: 5.0.0(vitest@5.0.0) + version: 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) chalk: specifier: 4.1.2 version: 4.1.2 @@ -355,7 +355,7 @@ importers: version: 11.1.1 vitest: specifier: 5.0.0 - version: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + version: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) zx: specifier: catalog:repo version: 8.8.5 @@ -627,7 +627,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.0 - version: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + version: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) packages/expo: dependencies: @@ -796,6 +796,9 @@ importers: specifier: catalog:repo version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: + '@clerk/clerk-js': + specifier: workspace:^ + version: link:../clerk-js '@stylexjs/eslint-plugin': specifier: 0.19.0 version: 0.19.0 @@ -826,6 +829,9 @@ importers: '@types/react-dom': specifier: catalog:react version: 18.3.7(@types/react@18.3.28) + '@vitest/browser-playwright': + specifier: 5.0.0 + version: 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) axe-core: specifier: ^4.11.3 version: 4.11.4 @@ -835,6 +841,12 @@ importers: happy-dom: specifier: ^20.8.9 version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) + msw: + specifier: 2.15.0 + version: 2.15.0(@types/node@25.6.0)(typescript@6.0.3) + playwright: + specifier: 1.62.1 + version: 1.62.1 qrcode.react: specifier: 4.2.0 version: 4.2.0(react@18.3.1) @@ -2238,6 +2250,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@bomb.sh/tab@0.0.19': resolution: {integrity: sha512-dTRfo9Q9B+lbLG3JCu8a/AGQSfD2XXcFcnakQzVjSOX+VvR/s9zpsH8TlqV3iHqazniRn1Ypwd1hcRlXcu/4BA==} hasBin: true @@ -6682,6 +6697,17 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/browser-playwright@5.0.0': + resolution: {integrity: sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==} + peerDependencies: + playwright: '*' + vitest: 5.0.0 + + '@vitest/browser@5.0.0': + resolution: {integrity: sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==} + peerDependencies: + vitest: 5.0.0 + '@vitest/coverage-v8@5.0.0': resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} peerDependencies: @@ -6710,9 +6736,20 @@ packages: vite: optional: true + '@vitest/pretty-format@5.0.0': + resolution: {integrity: sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==} + '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/ui@5.0.0': + resolution: {integrity: sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==} + peerDependencies: + vitest: 5.0.0 + + '@vitest/utils@5.0.0': + resolution: {integrity: sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==} + '@volar/language-core@2.4.27': resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==} @@ -9414,6 +9451,9 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -9497,6 +9537,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -12437,6 +12480,10 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + portfinder@1.0.38: resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} engines: {node: '>= 10.12'} @@ -16545,6 +16592,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.10.0': {} + '@bomb.sh/tab@0.0.19(cac@6.7.14)(citty@0.2.2)(commander@14.0.3)': optionalDependencies: cac: 6.7.14 @@ -21932,7 +21981,104 @@ snapshots: vite: 8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) vue: 3.5.42(typescript@6.0.3) - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.1 @@ -21942,7 +22088,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + optionalDependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -21980,8 +22128,28 @@ snapshots: msw: 2.15.0(@types/node@25.6.0)(typescript@6.0.3) vite: 8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + '@vitest/pretty-format@5.0.0': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/spy@5.0.0': {} + '@vitest/ui@5.0.0(vitest@5.0.0)': + dependencies: + '@vitest/utils': 5.0.0 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + + '@vitest/utils@5.0.0': + dependencies: + '@vitest/pretty-format': 5.0.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@volar/language-core@2.4.27': dependencies: '@volar/source-map': 2.4.27 @@ -25415,6 +25583,8 @@ snapshots: fflate@0.8.2: {} + fflate@0.8.3: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -25524,6 +25694,8 @@ snapshots: flatted@3.4.2: {} + flatted@3.4.4: {} + flattie@1.1.1: {} flow-enums-runtime@0.0.6: {} @@ -29292,6 +29464,8 @@ snapshots: pngjs@5.0.0: {} + pngjs@7.0.0: {} + portfinder@1.0.38: dependencies: async: 3.2.6 @@ -32084,13 +32258,13 @@ snapshots: dom-accessibility-api: 0.5.16 lodash-es: 4.18.1 redent: 3.0.0 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest-chrome@0.1.0: dependencies: '@types/chrome': 0.0.114 - vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) @@ -32110,13 +32284,15 @@ snapshots: '@edge-runtime/vm': 5.0.0 '@opentelemetry/api': 1.9.0 '@types/node': 22.20.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/browser-playwright': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) happy-dom: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) jsdom: 27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - msw - vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) @@ -32136,13 +32312,15 @@ snapshots: '@edge-runtime/vm': 5.0.0 '@opentelemetry/api': 1.9.0 '@types/node': 22.20.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/browser-playwright': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) happy-dom: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) jsdom: 27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - msw - vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) @@ -32162,7 +32340,9 @@ snapshots: '@edge-runtime/vm': 5.0.0 '@opentelemetry/api': 1.9.0 '@types/node': 25.6.0 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/browser-playwright': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) happy-dom: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) jsdom: 27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index f399ea47e80..8e9f82f6742 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -409,7 +409,7 @@ the criteria and worked before/afters for the calls in between. Views take plain props and callbacks. They branch on the props the controller derived — `open`, `pendingKey`, an absent callback — never on a machine snapshot, -so a view test needs neither the machine nor Clerk: +so the view needs neither the machine nor Clerk: ```tsx export function UserButtonView({ open, onOpenChange, pendingKey, onSignOutAll, ...data }: UserButtonProps) { @@ -438,19 +438,17 @@ half-typed confirmation phrase and compares it, while `open`, `isDeleting`, and `errorMessage` come from the controller, because those are what decide whether the dialog closes or explains itself. -### Testing the layers +### Testing a flow -Each layer is tested in isolation, and that isolation is the point — the model is -the only test that mocks Clerk, and the view needs no machinery at all. See the -`mosaic` skill's `references/testing.md` for the recipes. +A flow is tested as a whole, not per layer. The props between layers are +internal and change as the flow grows, so tests pinned to them churn without +proving what the user sees. See the `mosaic` skill's `references/testing.md`. -| Layer | Test file | What it needs | -| ---------- | ------------------------ | ----------------------------------------------- | -| model | `*.model.test.tsx` | Mocked Clerk. The highest-risk layer. | -| controller | `*.controller.test.tsx` | A fake model object. No Clerk. | -| view | `*.view.test.tsx` | Plain props and `vi.fn()` callbacks. | -| wrapper | `*.test.tsx` | All three layers mocked; asserts the branching. | -| whole | `*.integration.test.tsx` | Mocked Clerk, real layers, real DOM. | +| Tier | Test file | What it needs | +| ------- | -------------------- | -------------------------------------------------------------- | +| unit | `*.test.ts(x)` | Nothing, or fake timers. Pure helpers and shared primitives. | +| feature | `*.feature.test.tsx` | Real `Clerk` and real layers in Chromium, FAPI faked with MSW. | +| E2E | `/integration` | Real apps against a real Clerk backend (the Playwright suite). | ## Coexistence with existing system @@ -512,7 +510,8 @@ The steps above cover the **styling** migration. For **flow** components — whe | `src/utils/reset.test.tsx` | Reset specs | | `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | | `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | -| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | +| `src/__tests__/feature/` | FAPI builders, the fake FAPI, and `renderWithClerk` for feature tests | +| `src/features/user-button/__tests__/` | `user-button.feature.test.tsx` is the feature test to copy from | `machine/` is the runtime; `machines/` is machines written with it. The one-letter difference is easy to misread — a feature's own machine belongs in its From 9aa3d87c79d044cfa99e9ffbd2fba949ba87b639 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 15:16:52 -0400 Subject: [PATCH 02/11] fix(mosaic): share one vitest copy with the browser provider and resolve setup files absolutely --- package.json | 1 + packages/mosaic/package.json | 1 - packages/mosaic/vitest.config.mts | 4 ++-- pnpm-lock.yaml | 24 ++++++++++-------------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 54eddc34738..ad304ef0e4b 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@vitejs/plugin-react": "^4.7.0", + "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", "chalk": "4.1.2", "citty": "^0.2.2", diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 51b4e48ea3e..60b8760c368 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -78,7 +78,6 @@ "@testing-library/user-event": "^14.6.5", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", - "@vitest/browser-playwright": "5.0.0", "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", "happy-dom": "^20.8.9", diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index bbcd57f5afe..fd1a0353c67 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -43,7 +43,7 @@ export default defineConfig({ name: 'mosaic', include: ['**/*.test.?(c|m)[jt]s?(x)', '**/*.spec.?(c|m)[jt]s?(x)'], exclude: ['src/primitives/**', featureTests], - setupFiles: ['./vitest.setup.mts'], + setupFiles: [resolve(import.meta.dirname, 'vitest.setup.mts')], }, }, { @@ -62,7 +62,7 @@ export default defineConfig({ test: { name: 'feature', include: [featureTests], - setupFiles: ['./vitest.setup.browser.mts'], + setupFiles: [resolve(import.meta.dirname, 'vitest.setup.browser.mts')], browser: { enabled: true, headless: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f48dc0a4d3..ca30bbc5eb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/browser-playwright': + specifier: 5.0.0 + version: 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) '@vitest/coverage-v8': specifier: 5.0.0 version: 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) @@ -829,9 +832,6 @@ importers: '@types/react-dom': specifier: catalog:react version: 18.3.7(@types/react@18.3.28) - '@vitest/browser-playwright': - specifier: 5.0.0 - version: 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) axe-core: specifier: ^4.11.3 version: 4.11.4 @@ -9448,9 +9448,6 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -12898,6 +12895,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -15548,7 +15546,7 @@ snapshots: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 - fflate: 0.8.2 + fflate: 0.8.3 lru-cache: 11.3.5 semver: 7.7.4 typescript: 5.6.1-rc @@ -21993,7 +21991,6 @@ snapshots: - msw - utf-8-validate - vite - optional: true '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': dependencies: @@ -22021,6 +22018,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': dependencies: @@ -22039,7 +22037,6 @@ snapshots: - msw - utf-8-validate - vite - optional: true '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0)': dependencies: @@ -22077,6 +22074,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/coverage-v8@5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0)': dependencies: @@ -22088,9 +22086,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -22142,7 +22140,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/utils@5.0.0': dependencies: @@ -25581,8 +25579,6 @@ snapshots: fetchdts@0.1.7: {} - fflate@0.8.2: {} - fflate@0.8.3: {} figures@3.2.0: From ea1ae1062e781aa6cd689ca9669b8c48b2997f68 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 15:26:07 -0400 Subject: [PATCH 03/11] fix(mosaic): match unhandled fake FAPI requests by origin --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 514aedbfc20..fa57c7a9de6 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -33,7 +33,7 @@ export function startWorker() { return worker.start({ quiet: true, onUnhandledRequest: request => { - if (request.url.startsWith(FAPI)) { + if (new URL(request.url).origin === FAPI) { unhandled.push(`${request.method} ${request.url}`); } }, From 22ed77f605cff5bb8245df34f66e96834dba065e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 15:44:46 -0400 Subject: [PATCH 04/11] fix(repo): keep attw on fflate 0.8.2 --- pnpm-lock.yaml | 8 +++++++- pnpm-workspace.yaml | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca30bbc5eb4..caab614d9cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,7 @@ catalogs: version: 2.0.1 overrides: + '@arethetypeswrong/core>fflate': 0.8.2 '@astrojs/compiler': 2.13.0 '@babel/plugin-transform-modules-systemjs@<=7.29.3': 7.29.8 axios@<0.33.0: 0.33.0 @@ -9448,6 +9449,9 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -15546,7 +15550,7 @@ snapshots: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 - fflate: 0.8.3 + fflate: 0.8.2 lru-cache: 11.3.5 semver: 7.7.4 typescript: 5.6.1-rc @@ -25579,6 +25583,8 @@ snapshots: fetchdts@0.1.7: {} + fflate@0.8.2: {} + fflate@0.8.3: {} figures@3.2.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd061700949..fb0545c812d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,6 +41,7 @@ catalogs: '@zxcvbn-ts/language-common': 3.0.4 overrides: + '@arethetypeswrong/core>fflate': 0.8.2 '@astrojs/compiler': 2.13.0 '@babel/plugin-transform-modules-systemjs@<=7.29.3': 7.29.8 'axios@<0.33.0': 0.33.0 From 0deb9bf2bd2a6097df95a3005fd8c93e951e96e2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 13:22:56 -0400 Subject: [PATCH 05/11] docs(mosaic): make feature tests the default, not the only option --- .claude/skills/mosaic/references/testing.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 758261cba57..5106c13c396 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -33,10 +33,17 @@ Rule of thumb: **test each behavior once, at the boundary that owns it.** Visual states (loading, empty, every error, every variant) are swingset stories with plain props, reviewed by eye. Don't duplicate them as view tests. -Do **not** add per-layer model, controller, view, or wrapper tests with mocked -layers. If a behavior is visible to the user, the feature test owns it. Some -features still carry per-layer tests from before this rule; delete them once a -feature test covers the same behavior. +Default to a feature test for behavior the user can see. Reach for a smaller +test when it is the better tool, for example: + +- Logic with many combinations (ordering, labels, layout rules). Pull it into + a pure function and unit-test the table. +- States or timing that are awkward to produce through FAPI. +- A bug that is much easier to pin down at a single layer. + +Avoid per-layer tests with mocked layers that repeat what a feature test +already covers. Older per-layer tests like that can go once a feature test +covers the same behavior. ## Running From 71824eb3e172eb20ec2247caf87376ddb5dae4c8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 13:54:42 -0400 Subject: [PATCH 06/11] test(mosaic): match the reworked user button list in feature tests --- .../__tests__/user-button.feature.test.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 0f4039f2ef7..a3158311600 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -433,7 +433,7 @@ describe('UserButton', () => { const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Create organization'); + await user.click(await screen.findByRole('button', { name: 'Create organization' })); expect(openCreateOrganization).toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); @@ -445,7 +445,7 @@ describe('UserButton', () => { const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Create organization'); + await user.click(await screen.findByRole('button', { name: 'Create organization' })); expect(navigate).toHaveBeenCalledWith('/new-org'); expect(openCreateOrganization).not.toHaveBeenCalled(); @@ -458,11 +458,10 @@ describe('UserButton', () => { {}, signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: restricted }), bobSession]) }), ); - const user = await open(); - await user.click(accountMenu()); + await open(); - expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull(); + expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); }); }); @@ -477,7 +476,7 @@ describe('UserButton', () => { expect(spinner()).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Switch account' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true'); expect(popup()).toBeInTheDocument(); touch.release(); From 09a0d22b64adff957c7a6e61cd3f2a840b7ea423 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:14:08 -0400 Subject: [PATCH 07/11] docs(mosaic): say what belongs in e2e and what in feature tests --- .claude/skills/mosaic/references/testing.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 5106c13c396..39a52aec92f 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -33,6 +33,20 @@ Rule of thumb: **test each behavior once, at the boundary that owns it.** Visual states (loading, empty, every error, every variant) are swingset stories with plain props, reviewed by eye. Don't duplicate them as view tests. +Each behavior lives in one tier. A feature test owns everything the feature +does on screen, including edge cases, busy states, and errors. E2E only proves +what a faked FAPI can't: + +- The framework SDK mounts the component. +- An action really changes the session: sign-out clears it, a switch survives a + reload. +- A route the component sends the user to really exists. +- Components from another package, such as the `UserProfile` modal, render + what Mosaic hands them. + +Keep a feature's E2E to one short smoke file. Don't repeat modes, orderings, or +failure cases there. + Default to a feature test for behavior the user can see. Reach for a smaller test when it is the better tool, for example: From 94b6da9dc0d738262d9df0144d0c34fc842663f2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:39:47 -0400 Subject: [PATCH 08/11] docs(mosaic): make feature tests the default in the layer guides too --- .claude/skills/mosaic/references/controllers.md | 6 +++--- .claude/skills/mosaic/references/migration.md | 3 ++- .claude/skills/mosaic/references/models.md | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.claude/skills/mosaic/references/controllers.md b/.claude/skills/mosaic/references/controllers.md index 59d5011827a..c1481058f84 100644 --- a/.claude/skills/mosaic/references/controllers.md +++ b/.claude/skills/mosaic/references/controllers.md @@ -122,7 +122,7 @@ have a machine. ## Testing -The controller is covered by the feature test, not a test of its own. Hold the +The feature test covers the controller by default. Hold the FAPI request an action makes to assert its in-flight state (the spinner, the rows -stood down), then release or fail it to assert what closes the surface. See -`testing.md`. +stood down), then release or fail it to assert what closes the surface. Timing +that is awkward to drive through FAPI can get a smaller test. See `testing.md`. diff --git a/.claude/skills/mosaic/references/migration.md b/.claude/skills/mosaic/references/migration.md index c1f80487c6f..c4da87fe3f0 100644 --- a/.claude/skills/mosaic/references/migration.md +++ b/.claude/skills/mosaic/references/migration.md @@ -80,7 +80,8 @@ holds the state"). Test the feature with one `*.feature.test.tsx` that uses it the way a user would, against a real Clerk with FAPI faked (`testing.md`). Turn each inventory -row into a test there, not into per-layer tests. The **model** is the +row into a test there by default, and reach for a smaller test only where +`testing.md` says it is the better tool. The **model** is the highest-risk layer, so make sure its rows (revalidation, permission gates, empty states) each have one. diff --git a/.claude/skills/mosaic/references/models.md b/.claude/skills/mosaic/references/models.md index a1c0465d2e8..44858716d3a 100644 --- a/.claude/skills/mosaic/references/models.md +++ b/.claude/skills/mosaic/references/models.md @@ -89,8 +89,9 @@ drops the fallback instead of holding the space open. Keep the two apart. ## Testing -The model is covered by the feature's feature test, which runs it against a real -Clerk with FAPI faked, rather than by a test of its own. It is the +The feature test covers the model by default, running it against a real Clerk +with FAPI faked. It is the **highest-risk layer**: when a migration loses behavior, it is usually a model responsibility (revalidate timing, a permission gate, an empty-state rule) that -quietly went missing, so give those cases a feature test each. See `testing.md`. +quietly went missing, so give those cases a feature test each. A rule with many +combinations can move into a pure function with a unit test. See `testing.md`. From 6f41a214bfc0e7ecf99fef476aab2f3aa0de4fa7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:40:54 -0400 Subject: [PATCH 09/11] test(mosaic): fail and release held FAPI requests a test leaves open --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 21 +++++++++++++++++-- packages/mosaic/vitest.setup.browser.mts | 3 ++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index fa57c7a9de6..a1e20d1d345 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -163,12 +163,29 @@ export interface HeldRequests { fail: (code?: string) => void; } +const unsettledHolds = new Map void>(); + +export function takeUnsettledHolds(): string[] { + const holds = [...unsettledHolds.entries()]; + unsettledHolds.clear(); + for (const [, release] of holds) { + release(); + } + return holds.map(([hold]) => hold); +} + export function holdRequests(method: 'get' | 'post', path: string): HeldRequests { const requests: Request[] = []; - let settle: (response: Response | undefined) => void = () => {}; + const hold = `${method.toUpperCase()} ${path}`; + let resolveGate: (response: Response | undefined) => void = () => {}; const gate = new Promise(resolve => { - settle = resolve; + resolveGate = resolve; }); + const settle = (response: Response | undefined) => { + unsettledHolds.delete(hold); + resolveGate(response); + }; + unsettledHolds.set(hold, () => resolveGate(undefined)); worker.use( http[method](fapiUrl(path), ({ request }) => { diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts index c874f106507..424c32efe5e 100644 --- a/packages/mosaic/vitest.setup.browser.mts +++ b/packages/mosaic/vitest.setup.browser.mts @@ -3,7 +3,7 @@ import * as matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, beforeEach, expect } from 'vitest'; -import { startWorker, takeUnhandledRequests, worker } from './src/__tests__/feature/fake-fapi'; +import { startWorker, takeUnhandledRequests, takeUnsettledHolds, worker } from './src/__tests__/feature/fake-fapi'; expect.extend(matchers); @@ -20,6 +20,7 @@ afterEach(() => { __resetClerkQueryClientForTest(); worker.resetHandlers(); expect(takeUnhandledRequests(), 'Frontend API requests without a handler').toEqual([]); + expect(takeUnsettledHolds(), 'Held Frontend API requests never released or failed').toEqual([]); }); afterAll(() => { From a185ae766dd00d9d2bf4f03040e56e5db5f9aa9a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:50:18 -0400 Subject: [PATCH 10/11] test(mosaic): release every held FAPI request before asserting teardown --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 19 ++++++++++++------- packages/mosaic/vitest.setup.browser.mts | 6 ++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index a1e20d1d345..dcb0993b281 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -163,29 +163,34 @@ export interface HeldRequests { fail: (code?: string) => void; } -const unsettledHolds = new Map void>(); +interface Hold { + name: string; + release: () => void; +} + +const unsettledHolds = new Set(); export function takeUnsettledHolds(): string[] { - const holds = [...unsettledHolds.entries()]; + const holds = [...unsettledHolds]; unsettledHolds.clear(); - for (const [, release] of holds) { - release(); + for (const hold of holds) { + hold.release(); } - return holds.map(([hold]) => hold); + return holds.map(hold => hold.name); } export function holdRequests(method: 'get' | 'post', path: string): HeldRequests { const requests: Request[] = []; - const hold = `${method.toUpperCase()} ${path}`; let resolveGate: (response: Response | undefined) => void = () => {}; const gate = new Promise(resolve => { resolveGate = resolve; }); + const hold: Hold = { name: `${method.toUpperCase()} ${path}`, release: () => resolveGate(undefined) }; const settle = (response: Response | undefined) => { unsettledHolds.delete(hold); resolveGate(response); }; - unsettledHolds.set(hold, () => resolveGate(undefined)); + unsettledHolds.add(hold); worker.use( http[method](fapiUrl(path), ({ request }) => { diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts index 424c32efe5e..0e23f419066 100644 --- a/packages/mosaic/vitest.setup.browser.mts +++ b/packages/mosaic/vitest.setup.browser.mts @@ -19,8 +19,10 @@ afterEach(() => { cleanup(); __resetClerkQueryClientForTest(); worker.resetHandlers(); - expect(takeUnhandledRequests(), 'Frontend API requests without a handler').toEqual([]); - expect(takeUnsettledHolds(), 'Held Frontend API requests never released or failed').toEqual([]); + const unhandled = takeUnhandledRequests(); + const unsettled = takeUnsettledHolds(); + expect(unhandled, 'Frontend API requests without a handler').toEqual([]); + expect(unsettled, 'Held Frontend API requests never released or failed').toEqual([]); }); afterAll(() => { From dd9fa5bac62f517ff1dae14013f0b5b42f979ec4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 18:35:58 -0400 Subject: [PATCH 11/11] fix(repo): upgrade attw to 0.18.3 instead of pinning fflate --- package.json | 2 +- pnpm-lock.yaml | 38 ++++++++++++++++---------------------- pnpm-workspace.yaml | 1 - 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index ad304ef0e4b..950eda61070 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, "devDependencies": { "@actions/core": "^1.11.1", - "@arethetypeswrong/cli": "0.18.2", + "@arethetypeswrong/cli": "0.18.3", "@changesets/cli": "^2.31.1", "@changesets/get-github-info": "^0.8.0", "@clerk/backend": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caab614d9cc..df232371ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,6 @@ catalogs: version: 2.0.1 overrides: - '@arethetypeswrong/core>fflate': 0.8.2 '@astrojs/compiler': 2.13.0 '@babel/plugin-transform-modules-systemjs@<=7.29.3': 7.29.8 axios@<0.33.0: 0.33.0 @@ -121,8 +120,8 @@ importers: specifier: ^1.11.1 version: 1.11.1 '@arethetypeswrong/cli': - specifier: 0.18.2 - version: 0.18.2 + specifier: 0.18.3 + version: 0.18.3 '@changesets/cli': specifier: ^2.31.1 version: 2.31.1(@types/node@22.20.1) @@ -329,7 +328,7 @@ importers: version: 1.2.2 tsdown: specifier: catalog:repo - version: 0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) + version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) tsup: specifier: catalog:repo version: 8.5.1(@microsoft/api-extractor@7.58.7(@types/node@22.20.1))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0) @@ -625,7 +624,7 @@ importers: version: 9.31.0(jiti@2.7.0) tsdown: specifier: catalog:repo - version: 0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) + version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) typescript: specifier: catalog:repo version: 6.0.3 @@ -859,7 +858,7 @@ importers: version: 18.3.1(react@18.3.1) tsdown: specifier: catalog:repo - version: 0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) + version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) vitest-axe: specifier: ^0.1.0 version: 0.1.0(vitest@5.0.0) @@ -1321,7 +1320,7 @@ importers: version: 10.2.6 tsdown: specifier: catalog:repo - version: 0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) + version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) unplugin: specifier: ^2.3.11 version: 2.3.11 @@ -1435,13 +1434,13 @@ packages: '@andrewbranch/untar.js@1.0.3': resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} - '@arethetypeswrong/cli@0.18.2': - resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} + '@arethetypeswrong/cli@0.18.3': + resolution: {integrity: sha512-GeAlc+lUD4gKHD/LDQNvQY30FfQ+xAXg2inbQKUjFZgTOdI5ygEweaOnGHGBPSKXSLGQC7VLhpXu9zMnYk/4sQ==} engines: {node: '>=20'} hasBin: true - '@arethetypeswrong/core@0.18.2': - resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} + '@arethetypeswrong/core@0.18.3': + resolution: {integrity: sha512-sWBB/tdIktaT5xMq0Dz6CJyqcf6oMNdmiKiuPU1lWoJLTL6gjRSsksBuSgqot21hylkklBQY1wiSu+PkZhW7sw==} engines: {node: '>=20'} '@asamuzakjp/css-color@3.2.0': @@ -9449,9 +9448,6 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -15535,9 +15531,9 @@ snapshots: '@andrewbranch/untar.js@1.0.3': {} - '@arethetypeswrong/cli@0.18.2': + '@arethetypeswrong/cli@0.18.3': dependencies: - '@arethetypeswrong/core': 0.18.2 + '@arethetypeswrong/core': 0.18.3 chalk: 4.1.2 cli-table3: 0.6.5 commander: 10.0.1 @@ -15545,12 +15541,12 @@ snapshots: marked-terminal: 7.3.0(marked@9.1.6) semver: 7.7.4 - '@arethetypeswrong/core@0.18.2': + '@arethetypeswrong/core@0.18.3': dependencies: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 - fflate: 0.8.2 + fflate: 0.8.3 lru-cache: 11.3.5 semver: 7.7.4 typescript: 5.6.1-rc @@ -25583,8 +25579,6 @@ snapshots: fetchdts@0.1.7: {} - fflate@0.8.2: {} - fflate@0.8.3: {} figures@3.2.0: @@ -31535,7 +31529,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)): + tsdown@0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -31553,7 +31547,7 @@ snapshots: tree-kill: 1.2.2 unconfig-core: 7.5.0 optionalDependencies: - '@arethetypeswrong/core': 0.18.2 + '@arethetypeswrong/core': 0.18.3 publint: 0.3.18 tsx: 4.20.6 typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb0545c812d..fd061700949 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,6 @@ catalogs: '@zxcvbn-ts/language-common': 3.0.4 overrides: - '@arethetypeswrong/core>fflate': 0.8.2 '@astrojs/compiler': 2.13.0 '@babel/plugin-transform-modules-systemjs@<=7.29.3': 7.29.8 'axios@<0.33.0': 0.33.0