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..c1481058f84 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 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. 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 5e7d0445e9b..c4da87fe3f0 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,16 @@ 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 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. ## 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..44858716d3a 100644 --- a/.claude/skills/mosaic/references/models.md +++ b/.claude/skills/mosaic/references/models.md @@ -89,9 +89,9 @@ 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 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. A rule with many +combinations can move into a pure function with a unit test. See `testing.md`. diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 1b80035d6c9..39a52aec92f 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -1,194 +1,170 @@ # 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. + +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: + +- 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 + +```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/package.json b/package.json index 54eddc34738..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:*", @@ -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 e7aa9d63c84..60b8760c368 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", @@ -79,6 +81,8 @@ "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..dcb0993b281 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -0,0 +1,208 @@ +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 (new URL(request.url).origin === 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; +} + +interface Hold { + name: string; + release: () => void; +} + +const unsettledHolds = new Set(); + +export function takeUnsettledHolds(): string[] { + const holds = [...unsettledHolds]; + unsettledHolds.clear(); + for (const hold of holds) { + hold.release(); + } + return holds.map(hold => hold.name); +} + +export function holdRequests(method: 'get' | 'post', path: string): HeldRequests { + const requests: Request[] = []; + 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.add(hold); + + 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..a3158311600 --- /dev/null +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -0,0 +1,594 @@ +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 user.click(await screen.findByRole('button', { name: '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 user.click(await screen.findByRole('button', { name: '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]) }), + ); + await open(); + + expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { 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' })).toHaveAttribute('aria-disabled', 'true'); + 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..fd1a0353c67 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: [resolve(import.meta.dirname, '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: [resolve(import.meta.dirname, '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..0e23f419066 --- /dev/null +++ b/packages/mosaic/vitest.setup.browser.mts @@ -0,0 +1,30 @@ +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, takeUnsettledHolds, worker } from './src/__tests__/feature/fake-fapi'; + +expect.extend(matchers); + +beforeAll(async () => { + await startWorker(); +}); + +beforeEach(() => { + window.focus(); +}); + +afterEach(() => { + cleanup(); + __resetClerkQueryClientForTest(); + worker.resetHandlers(); + 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(() => { + 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..df232371ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,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) @@ -188,9 +188,12 @@ 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@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 @@ -325,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) @@ -355,7 +358,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 @@ -621,13 +624,13 @@ 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 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 +799,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 @@ -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) @@ -846,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) @@ -1308,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 @@ -1422,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': @@ -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==} @@ -9411,8 +9448,8 @@ 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==} figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -9497,6 +9534,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 +12477,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'} @@ -12851,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: @@ -15486,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 @@ -15496,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 @@ -16545,6 +16590,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 +21979,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 + + '@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 + 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: + '@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 + + '@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 + optional: true + + '@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 +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@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)) + optionalDependencies: + '@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': {} @@ -21980,8 +22126,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@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: + '@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 @@ -25413,7 +25579,7 @@ snapshots: fetchdts@0.1.7: {} - fflate@0.8.2: {} + fflate@0.8.3: {} figures@3.2.0: dependencies: @@ -25524,6 +25690,8 @@ snapshots: flatted@3.4.2: {} + flatted@3.4.4: {} + flattie@1.1.1: {} flow-enums-runtime@0.0.6: {} @@ -29292,6 +29460,8 @@ snapshots: pngjs@5.0.0: {} + pngjs@7.0.0: {} + portfinder@1.0.38: dependencies: async: 3.2.6 @@ -31359,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 @@ -31377,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 @@ -32084,13 +32254,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 +32280,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 +32308,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 +32336,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