diff --git a/.claude/agents/qa.md b/.claude/agents/qa.md
index 3fd4180f..29abf32d 100644
--- a/.claude/agents/qa.md
+++ b/.claude/agents/qa.md
@@ -12,8 +12,8 @@ claims, including regression paths, loading/empty/error states, mobile
layouts, and denied access where relevant.
- Unit/integration: the narrowest `vp run test` scope that covers the slice.
-- Browser: `just e2e-local` (`vp run test:e2e:local`) against Vite on
- `http://localhost:5173`, not the Convex site proxy. It needs a running
+- Browser: `just journeys` (`vp run test:journeys`) runs the browser-mode
+ journeys in `apps/web/e2e` against the local backend. It needs a running
`just dev` stack; if none is running and you were not told you own one,
report that instead of starting it.
- Log in with the seeded demo owner `demo@groam.example` / `GroamDemo123!`.
@@ -21,7 +21,7 @@ layouts, and denied access where relevant.
- Adding coverage: read `add-web-view-test`, `add-convex-test`, or
`add-test-id` first, and edit only test files the brief names.
-Use `rg` to find existing specs under `apps/web/e2e/` and tests near the
+Use `rg` to find existing journeys under `apps/web/e2e/` and tests near the
changed code. No fabricated screenshots, skipped failures, or arbitrary sleeps.
Return per criterion: pass/fail, reproducible steps, actual versus expected.
diff --git a/.claude/skills/add-convex-test/SKILL.md b/.claude/skills/add-convex-test/SKILL.md
index 17e00c24..21a40769 100644
--- a/.claude/skills/add-convex-test/SKILL.md
+++ b/.claude/skills/add-convex-test/SKILL.md
@@ -10,7 +10,7 @@ description: >-
# Add a Convex test
Colocate `.test.ts` next to the module under
-`packages/backend/convex/modules/...` (Convex excludes `*.test.ts` from
+`packages/backend/convex/modules//` (Convex excludes `*.test.ts` from
deploys). Import test APIs from `vite-plus/test`, not `vitest`.
## Setup
@@ -62,6 +62,10 @@ test('adds an activity to a scheduled destination', { timeout: 15_000 }, async (
- Tests go through `client.query` / `client.mutation` / `client.action`;
`ctx.db` stays in modules and routes.
- Use `{ timeout: 15_000 }` as the floor for authenticated fixtures.
+- Scheduled functions wait on fake timers; drive them with
+ `finishAllScheduledFunctions(vi.runAllTimers)` or call `vi.useRealTimers()`.
+- Console output fails the test unless declared with `expectConsole(level,
+ pattern)` from `@groam/vitest-config/console`.
## Run
diff --git a/.claude/skills/add-env-variable/SKILL.md b/.claude/skills/add-env-variable/SKILL.md
index 265794ea..fd70b7fb 100644
--- a/.claude/skills/add-env-variable/SKILL.md
+++ b/.claude/skills/add-env-variable/SKILL.md
@@ -29,7 +29,7 @@ scripts.
| `runtime` | installed-app runtime | `GROAM_CONVEX_URL`, `GROAM_DATA_DIR` |
| `desktop-tooling` | `apps/desktop/scripts` | `VITE_PHOTON_URL`, `CI` |
| `seeder` | `tooling/seeder` | `SEED_CONVEX_URL`, `SEED_USER_EMAIL` |
-| `playwright` | web E2E | `PLAYWRIGHT_BASE_URL`, seed credentials |
+| `journeys` | browser journeys (`apps/web/e2e`) | `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL` |
| `showcase` | `tooling/showcase` | `SHOWCASE_*` |
| `vitest` | `tooling/vitest-config` | `CI` |
diff --git a/.claude/skills/add-test-id/SKILL.md b/.claude/skills/add-test-id/SKILL.md
index b878a8ae..29896710 100644
--- a/.claude/skills/add-test-id/SKILL.md
+++ b/.claude/skills/add-test-id/SKILL.md
@@ -1,7 +1,7 @@
---
name: add-test-id
description: >-
- Add a stable data-testid for Groam Playwright and Testing Library locators.
+ Add a stable data-testid for Groam browser-mode view tests and journeys.
Use when a new control, dialog, row, or empty-state action needs a test
hook — ids live in @groam/app-actions/ids, never as inline strings in one
app.
@@ -10,7 +10,7 @@ description: >-
# Add a test id
Stable locators are the `ids` object in
-`tooling/app-actions/src/playwright/ids.ts`, published as
+`tooling/app-actions/src/testing/ids.ts`, published as
`@groam/app-actions/ids`. The web app re-exports it as `testIds` from
`@/lib/test-ids`.
@@ -31,13 +31,18 @@ Stable locators are the `ids` object in
```
-3. Query the same key in tests: `screen.getByTestId(testIds.agentStop)` in
- Testing Library (`add-web-view-test`), `page.getByTestId(ids.agentStop)`
- in Playwright.
-4. For a repeated browser journey, add or reuse a helper in
- `tooling/app-actions/src/playwright/.ts` (exported from its
- `index.ts`) instead of repeating the steps in a spec under
- `apps/web/e2e/`.
+3. Query the same key in tests: `page.getByTestId(testIds.agentStop)` in
+ view tests (`add-web-view-test`) and journeys.
+4. For a repeated browser step, add or reuse a helper in
+ `tooling/app-actions/src/vitest/.ts` (exported from its
+ `index.ts`) built on `page` locators from `vite-plus/test/browser`,
+ instead of repeating the steps in a test.
+5. View tests and journeys use the same steps. Journeys
+ (`apps/web/e2e/**/*.test.tsx`) mount the whole app with `openApp(path)`
+ from `@/testing/open-app`, signed in as the file's own account and group;
+ `arrange` from `@groam/app-actions/vitest` creates trips, ideas, issues,
+ and members through the backend, and other people act through it too.
+ Drive only the journey under test through the UI.
## Rules
diff --git a/.claude/skills/add-web-view-test/SKILL.md b/.claude/skills/add-web-view-test/SKILL.md
index 52bd5d81..9ac0ecca 100644
--- a/.claude/skills/add-web-view-test/SKILL.md
+++ b/.claude/skills/add-web-view-test/SKILL.md
@@ -1,7 +1,7 @@
---
name: add-web-view-test
description: >-
- Add a Vitest + Testing Library test for a Groam web feature view. Use when
+ Add a Vite+ browser-mode test for a Groam web feature view. Use when
covering a React screen, loading/empty/error state, dialog, or sidebar row
in apps/web — mock feature hooks and agent context, never spin up Convex
(use add-convex-test for backend behavior).
@@ -10,15 +10,18 @@ description: >-
# Add a web view test
Colocate `.test.tsx` next to the component in
-`apps/web/src/features//`. Mock the feature hooks, workspace state,
-router, and agent context; render the view; assert roles, copy, and test
-ids.
+`apps/web/src/features//`. View tests run in real Chromium (the
+`views` browser setup of `vp test`) with the app's styles. Mock the feature
+hooks, workspace state, router, and agent context; render the view; drive it
+with `page` locators; assert with `expect.element`, which retries. Testing
+Library and jsdom are not used.
## Pattern (from `agents/agent-list/agents-list-view.test.tsx`)
```tsx
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { testIds } from '@/lib/test-ids';
import { AgentsListView } from './agents-list-view';
@@ -51,23 +54,39 @@ beforeEach(() => {
state.isLoading = false;
state.runs = [];
});
-afterEach(cleanup);
describe('AgentsListView', () => {
- test('shows a labeled loading state', () => {
+ test('shows a labeled loading state', async () => {
state.isLoading = true;
- render();
- expect(screen.getByRole('status', { name: 'Loading agent runs…' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Loading agent runs…' })).toBeVisible();
});
- test('shows the empty state', () => {
- render();
- expect(screen.getByText('Nothing running yet')).toBeTruthy();
- expect(screen.queryByTestId(testIds.agentsRoster)).toBeNull();
+ test('shows the empty state', async () => {
+ await render();
+ await expect.element(page.getByText('Nothing running yet')).toBeVisible();
+ await expect.element(page.getByTestId(testIds.agentsRoster)).not.toBeInTheDocument();
});
});
```
+`render` and `renderHook` come from `@groam/vitest-config/react` and unmount
+after each test. Interact like a user: `await page.getByLabelText(...).fill()`,
+`.click()`. For many input combinations, drive the view through the shared
+steps in `@groam/app-actions/vitest` (for example `createTrip(input)`) with
+`test.each`, as `trip-create/create-trip-dialog.test.tsx` does.
+
+```tsx
+test.each<[string, CreateTripFormInput]>([
+ ['only a name', { name }],
+ ['a budget', { budget: 2500, name }]
+])('creates a trip with %s', async (_, input) => {
+ await render();
+ await fillCreateTrip(input);
+ await expect.poll(() => createTrip.mock.calls.length).toBe(1);
+});
+```
+
Reuse existing mocks before writing new ones: `@/testing/stub-match-media`,
`@/testing/trip-planner-fixture`, `features/trips/test-workspace-mocks.tsx`,
`features/agents/agent-test-router-mock.tsx`. Mock workspace state at
@@ -80,8 +99,12 @@ Reuse existing mocks before writing new ones: `@/testing/stub-match-media`,
`@groam/app-actions/ids` (`add-test-id`).
- Hook logic gets its own `hooks/use-.test.ts(x)`; `packages/ui`
primitives get a `*.test.tsx` beside the primitive.
+- Console output fails the test unless declared with `expectConsole(level,
+ pattern)` from `@groam/vitest-config/console`; never silence it with
+ `vi.spyOn(console, …)`.
## Run
-From `apps/web`: `vp test run src/features//.test.tsx`.
-Whole repo: `just test`.
+From `apps/web`: `vp test run --project views src/features//.test.tsx`.
+Whole repo: `just test`. Full flows against the backend are journeys
+(`apps/web/e2e`, `just journeys`); keep those few and cover combinations here.
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index af5679aa..df93b8af 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -12,7 +12,8 @@ COPY --from=uv /uv /uvx /usr/local/bin/
COPY --from=just /just /usr/local/bin/
# Tauri 2 Linux prerequisites (https://tauri.app/start/prerequisites/) plus the
-# extra packages from tauri-apps/tauri-action's GitHub workflow.
+# extra packages from tauri-apps/tauri-action's GitHub workflow. libnss3 and
+# libnspr4 are the only libraries Playwright's Chromium needs beyond them.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
@@ -20,6 +21,8 @@ RUN apt-get update \
file \
libayatana-appindicator3-dev \
libgtk-3-dev \
+ libnspr4 \
+ libnss3 \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
index 032d93ca..ab4bfa62 100644
--- a/.devcontainer/post-create.sh
+++ b/.devcontainer/post-create.sh
@@ -15,6 +15,10 @@ sudo chown -R "$(id -u):$(id -g)" "${persistent_dirs[@]}"
VP_GIT_HOOKS=0 vp install --frozen-lockfile
+# Browser-mode tests (`vp test`) need the Chromium build matching the locked
+# Playwright provider; its system libraries come from the Dockerfile.
+vp run test:browsers:install
+
# Interactive `claude` in bash and zsh uses the repository harness (Headroom,
# Caveman, MCP). An alias, not a PATH shim, so the harness still finds the real
# binary. Home is rebuilt with the container, so this reruns on every rebuild;
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4a7a0db0..260baf5f 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -56,20 +56,25 @@ just desktop
## Checks
-Run what CI runs before opening a pull request:
+Run the CI gate before opening a pull request:
```bash
just check
```
+`just check` covers audit, typecheck, lint, unit tests, React Doctor, and
+Fallow. CI also runs the browser journeys (`just journeys`, below) and the
+showcase recording (`bun run --cwd tooling/showcase test:browser`), and builds
+the desktop app on each platform; `just check` runs none of those.
+
Smaller loops:
-| Command | What it does |
-| ---------------- | ---------------------------------- |
-| `just lint` | Conventions, syncpack, `vp check` |
-| `just typecheck` | TypeScript |
-| `just test` | Unit tests |
-| `just e2e-local` | Playwright against Vite on `:5173` |
+| Command | What it does |
+| ---------------- | ----------------------------------------------------------------------------- |
+| `just lint` | Conventions, Claude harness, syncpack, `vp check` |
+| `just typecheck` | TypeScript |
+| `just test` | Unit and view tests |
+| `just journeys` | Browser journeys against the running local backend (start it with `just dev`) |
## Search the code graph
@@ -85,8 +90,6 @@ just graphify-query "trip access planner" # matches identifiers, not prose
Run `just graphify-refresh` after changing source files. The generated
`graphify-out/` directory is intentionally local and ignored by Git.
-
-
Graphify and its isolated Python environment are part of the container image;
nothing is installed into the host or the Bun workspace.
@@ -95,7 +98,7 @@ Do not hand-edit `apps/web/src/routeTree.gen.ts`. Add routes as files under
## Conventions
-- Import through package aliases (`@groam/ui`, `@groam/ai-contracts`, `@/features/…`),
+- Import through package aliases (`@groam/ui`, `@groam/app-actions`, `@/features/…`),
never parent `../` paths across packages
- Semantic color tokens only (no hex / rgb / Tailwind gradients in features)
- Public Convex functions live under `packages/backend/convex/routes` as
diff --git a/.github/actions/setup-chromium/action.yml b/.github/actions/setup-chromium/action.yml
new file mode 100644
index 00000000..98bf2ae5
--- /dev/null
+++ b/.github/actions/setup-chromium/action.yml
@@ -0,0 +1,17 @@
+name: Setup Chromium
+description: Install the Chromium build that Vite+ browser-mode tests drive, cached by lockfile
+
+runs:
+ using: composite
+ steps:
+ - name: Cache Chromium
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: chromium-${{ runner.os }}-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ chromium-${{ runner.os }}-
+
+ - name: Install Chromium
+ shell: bash
+ run: bun run test:browsers:install --with-deps
diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml
index 7b24002e..79fd3829 100644
--- a/.github/actions/setup-workspace/action.yml
+++ b/.github/actions/setup-workspace/action.yml
@@ -15,21 +15,12 @@ runs:
bun-version: '1.3.11'
- name: Cache Bun downloads
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- - name: Cache Turbo
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
- with:
- path: .turbo
- key: turbo-${{ runner.os }}-${{ hashFiles('bun.lock') }}-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-${{ hashFiles('bun.lock') }}-
- turbo-${{ runner.os }}-
-
- name: Install dependencies
shell: bash
env:
diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml
index 7d0c6b43..4a8394dc 100644
--- a/.github/workflows/bootstrap.yml
+++ b/.github/workflows/bootstrap.yml
@@ -14,12 +14,11 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
provision:
name: Create Convex project and environments
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
@@ -55,7 +54,7 @@ jobs:
deploy:
name: Bring ${{ matrix.environment }} online
needs: [provision, test]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 183eb9c8..4f74463b 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -20,7 +20,7 @@ permissions:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
diff --git a/.github/workflows/deploy-site-reusable.yml b/.github/workflows/deploy-site-reusable.yml
index 258673ad..2945c0bf 100644
--- a/.github/workflows/deploy-site-reusable.yml
+++ b/.github/workflows/deploy-site-reusable.yml
@@ -22,12 +22,11 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
deploy:
name: Deploy ${{ inputs.site }} to ${{ inputs.environment }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
environment:
name: ${{ inputs.environment }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 6e3db56e..a33d9f38 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -23,7 +23,6 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
test:
@@ -33,7 +32,7 @@ jobs:
backend:
name: Deploy backend and migrations to ${{ inputs.environment }}
needs: [test]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
environment:
name: ${{ inputs.environment }}
@@ -98,7 +97,7 @@ jobs:
smoke-test:
name: Smoke test ${{ inputs.environment }}
needs: [backend, sites]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 5
environment:
name: ${{ inputs.environment }}
diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml
index e3691d5f..d9a910ec 100644
--- a/.github/workflows/migrate.yml
+++ b/.github/workflows/migrate.yml
@@ -23,12 +23,11 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
migrate:
name: Run ${{ inputs.environment }} migrations
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: ${{ inputs.environment }}
diff --git a/.github/workflows/regenerate-showcase.yml b/.github/workflows/regenerate-showcase.yml
index cb20e0f1..823bfa34 100644
--- a/.github/workflows/regenerate-showcase.yml
+++ b/.github/workflows/regenerate-showcase.yml
@@ -15,12 +15,11 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
regenerate:
name: Regenerate showcase and open PR
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 240
steps:
- name: Checkout default branch
@@ -48,7 +47,6 @@ jobs:
env: |
CI
CONVEX_AGENT_MODE
- TURBO_TELEMETRY_DISABLED
push: never
runCmd: |
set -Eeuo pipefail
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 46b8c099..797fbbad 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ env:
jobs:
validate:
name: Validate release request
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Validate source and signing key
env:
@@ -61,7 +61,7 @@ jobs:
prepare:
name: Prepare GitHub Release
needs: [build]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.prep.outputs.tag }}
steps:
@@ -127,7 +127,7 @@ jobs:
name: Create GitHub Release
needs: [prepare]
environment: release
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Download verified release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml
index 1bc10819..8fee35ec 100644
--- a/.github/workflows/test-reusable.yml
+++ b/.github/workflows/test-reusable.yml
@@ -13,19 +13,19 @@ env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
- TURBO_TELEMETRY_DISABLED: '1'
jobs:
- quality:
- name: Quality
- runs-on: ubuntu-latest
- timeout-minutes: 60
+ lint:
+ name: 🧹 Lint
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
env:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ # Fallow's hotspot analysis reads git history.
fetch-depth: 0
persist-credentials: false
@@ -44,27 +44,24 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: Format check
- run: bun run format:check
+ - name: Lint
+ run: bun run lint
- - name: Lint, typecheck, and unit tests
- run: bun run check
+ - name: Audit dependencies
+ run: bun run audit
- e2e:
- name: Browser (${{ matrix.shard }}/4)
- needs: [quality]
- runs-on: ubuntu-latest
- timeout-minutes: 90
- strategy:
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4]
+ - name: React Doctor
+ run: bun run react-doctor
+
+ - name: Dead code
+ run: bun run fallow
+
+ typecheck:
+ name: 🔷 Typecheck
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
env:
CI: 'true'
- CONVEX_AGENT_MODE: anonymous
- # WebAuthn rejects IP addresses as relying-party domains. Keep the local
- # static site on the same loopback service, but address it by hostname.
- PLAYWRIGHT_BASE_URL: http://localhost:3211
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -74,20 +71,53 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: Cache Playwright browsers
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ - name: Typecheck
+ run: bun run typecheck
+
+ unit:
+ name: 🧪 Unit tests
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ env:
+ CI: 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- restore-keys: |
- playwright-${{ runner.os }}-
+ persist-credentials: false
- - name: Install Playwright browser
- run: bun run test:e2e:install --with-deps
+ - name: Setup workspace
+ uses: ./.github/actions/setup-workspace
+
+ - name: Setup Chromium
+ uses: ./.github/actions/setup-chromium
- - name: Test showcase recording
+ - name: Unit tests
+ run: bun run test
+
+ - name: Showcase recording
run: bun run --cwd tooling/showcase test:browser
+ journeys:
+ name: 🎭 Journeys
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ env:
+ CI: 'true'
+ CONVEX_AGENT_MODE: anonymous
+ STATIC_SITE_URL: http://localhost:3211
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Setup workspace
+ uses: ./.github/actions/setup-workspace
+
+ - name: Setup Chromium
+ uses: ./.github/actions/setup-chromium
+
- name: Start local Convex backend
shell: bash
run: |
@@ -99,8 +129,10 @@ jobs:
sleep 2
done
'
- bunx convex env set BETTER_AUTH_SECRET "ci-only-better-auth-secret-not-for-production"
- bunx convex env set SITE_URL "$PLAYWRIGHT_BASE_URL/"
+
+ # The same auth setup as local dev: a secret and the Vite site the passkey rpID comes from.
+ - name: Configure auth
+ run: bun tooling/devkit/configure-auth.ts
- name: Verify generated code
run: |
@@ -110,33 +142,32 @@ jobs:
- name: Run pending migrations
run: bun run migrations:run
+ # A failing journey keeps its Playwright trace (network, console, DOM) in the failure artifact.
+ - name: Journeys
+ run: bun run test:journeys -- --browser.trace=retain-on-failure
+
+ # The production build: published to the local static host and smoke-tested.
- name: Publish local static sites
run: bun run hosting:upload:web
- name: Verify static routes
shell: bash
run: |
- curl --fail --show-error --silent "$PLAYWRIGHT_BASE_URL/" >/dev/null
- curl --fail --show-error --silent "$PLAYWRIGHT_BASE_URL/settings" >/dev/null
- test "$(curl --output /dev/null --silent --write-out '%{http_code}' "$PLAYWRIGHT_BASE_URL/missing.js")" = "404"
-
- - name: Seed browser test data
- run: bun run seed -- --users 1 --trips 0
-
- - name: Run browser tests
- run: bun run test:e2e -- --shard=${{ matrix.shard }}/4
+ curl --fail --show-error --silent "$STATIC_SITE_URL/" >/dev/null
+ curl --fail --show-error --silent "$STATIC_SITE_URL/settings" >/dev/null
+ test "$(curl --output /dev/null --silent --write-out '%{http_code}' "$STATIC_SITE_URL/missing.js")" = "404"
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: test-failure-${{ github.run_id }}-${{ matrix.shard }}
+ name: test-failure-${{ github.run_id }}
if-no-files-found: ignore
+ include-hidden-files: true
retention-days: 7
path: |
/tmp/convex.log
- apps/web/playwright-report/
- apps/web/test-results/
+ apps/web/.vitest/
- name: Stop local Convex backend
if: always()
@@ -147,8 +178,7 @@ jobs:
fi
desktop:
- name: Desktop (${{ matrix.platform }})
- needs: [quality]
+ name: 🖥️ Desktop (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
@@ -164,11 +194,11 @@ jobs:
target: x86_64-unknown-linux-gnu
bundles: deb
- platform: macos-arm
- runner: macos-latest
+ runner: macos-26
target: aarch64-apple-darwin
bundles: app,dmg
- platform: windows
- runner: windows-latest
+ runner: windows-2025
target: x86_64-pc-windows-msvc
bundles: nsis
steps:
@@ -185,6 +215,19 @@ jobs:
with:
targets: ${{ matrix.target }}
+ # Dependencies dominate the release build; keep the bundles themselves out of the cache.
+ - name: Cache Rust build
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ apps/desktop/src-tauri/target/
+ !apps/desktop/src-tauri/target/*/release/bundle/
+ key: rust-${{ matrix.target }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
+ restore-keys: rust-${{ matrix.target }}-
+
- name: Install Linux desktop dependencies
if: matrix.platform == 'linux'
shell: bash
diff --git a/.github/workflows/update-convex-agent-files.yml b/.github/workflows/update-convex-agent-files.yml
index 92d8fa98..88cfda29 100644
--- a/.github/workflows/update-convex-agent-files.yml
+++ b/.github/workflows/update-convex-agent-files.yml
@@ -16,7 +16,7 @@ permissions:
jobs:
update:
name: Update files and open PR
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout default branch
diff --git a/.gitignore b/.gitignore
index bf6a61df..8c6598ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,12 +8,10 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
-# Legacy Turbo caches in clones from before Vite+.
-.turbo
+*.tsbuildinfo
.astro
dist
dist-ssr
-playwright-report
coverage
**/test-results
*.local
diff --git a/CLAUDE.md b/CLAUDE.md
index ca20b47a..f13a465c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -105,10 +105,10 @@ route, module, hook, env variable, test, or UI piece.
under `tooling/`; root configs such as `vite.config.ts` only compose it.
- **Enforced conventions:** `vp run lint:conventions` checks kebab-case file
and folder names, package-alias imports instead of `../`, semantic color
- tokens, `ctx.db` only in `modules/` and `routes/` (never in `'use node'`
- files), `@groam/env/*` instead of raw env access, public `@groam/ui`
- entrypoints, and thin route files. `vp run lint:claude` validates this
- harness.
+ tokens, `ctx.db` only in `components/`, `modules/`, and `routes/` (never in
+ `'use node'` files), `@groam/env/*` instead of raw env access, public
+ `@groam/ui` entrypoints, and thin route files. `vp run lint:claude`
+ validates this harness.
## Local runtime
@@ -118,9 +118,10 @@ route, module, hook, env variable, test, or UI piece.
- Ports: web `5173`, Convex API `3210`, Convex site/HTTP `3211`, dashboard
`6790`. `http://127.0.0.1:3211/` returns `503` locally because no static site
is uploaded; that is expected. The app is served by Vite on `5173`.
-- Browser e2e: `just e2e-local` against the running dev stack on
- `http://localhost:5173` (must match `VITE_SITE_URL` for Better Auth).
- `just e2e` targets the uploaded static site on `3211`, as CI does.
+- Browser tests run in Chromium through Vite+ browser mode (`vp test`). View
+ tests are the `views` setup; `just journeys` runs the `journeys` setup
+ (`apps/web/e2e`): the real app against the running local backend, served on
+ `5174`, which local Better Auth trusts.
- AI assistant features are optional and need an LLM provider key (see
`packages/env`).
diff --git a/Justfile b/Justfile
index 58498ba8..dac2f94c 100644
--- a/Justfile
+++ b/Justfile
@@ -67,15 +67,10 @@ typecheck:
test:
vp run test
-# Run browser tests against the CI-style static site.
+# Run browser journeys against the running local backend (`just dev`).
[group('quality')]
-e2e:
- vp run test:e2e
-
-# Run browser tests against Vite on localhost:5173.
-[group('quality')]
-e2e-local:
- vp run test:e2e:local
+journeys:
+ vp run test:journeys
# Format manifests and source files.
[group('quality')]
diff --git a/README.md b/README.md
index 1b35c370..8a777aca 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ Open [localhost:5173](http://localhost:5173) and sign in with
| `just check` | Run the complete CI quality suite |
| `just test` | Run unit and integration tests |
| `claude` | Start Claude Code with the repository harness (in the dev container) |
-| `just e2e-local` | Run Playwright against the local app |
+| `just journeys` | Run browser journeys against the local backend |
| `just desktop` | Start desktop development |
| `just graphify-explain ` | Show a symbol, its callers, and its dependencies |
| `just graphify-affected ` | List what a change to a symbol would affect |
diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore
index 124b16f3..e0b8072c 100644
--- a/apps/desktop/src-tauri/.gitignore
+++ b/apps/desktop/src-tauri/.gitignore
@@ -5,5 +5,8 @@
/groam-runtime/
/binaries/
-# Written by tooling/desktop/release-version.ts during release builds
+# Written by build.rs (tauri-build app manifest) for the custom commands
+/permissions/autogenerated/
+
+# Written by apps/desktop/scripts/updater-release.ts during release builds
/desktop-version.txt
diff --git a/apps/desktop/src-tauri/src/data_dir.rs b/apps/desktop/src-tauri/src/data_dir.rs
index f872e786..98eab838 100644
--- a/apps/desktop/src-tauri/src/data_dir.rs
+++ b/apps/desktop/src-tauri/src/data_dir.rs
@@ -6,24 +6,6 @@ use std::{
pub(crate) const RUNTIME_BUILD_STAMP: &str = ".groam-runtime-build";
-pub(crate) fn copy_dir(source: &Path, destination: &Path) -> io::Result<()> {
- fs::create_dir_all(destination)?;
- for entry in fs::read_dir(source)? {
- let entry = entry?;
- let target = destination.join(entry.file_name());
- let source_path = entry.path();
- let file_type = entry.file_type()?;
- if file_type.is_symlink() {
- copy_symlink(&source_path, &target)?;
- } else if file_type.is_dir() {
- copy_dir(&source_path, &target)?;
- } else if file_type.is_file() {
- fs::copy(&source_path, target)?;
- }
- }
- Ok(())
-}
-
fn copy_backup_dir(source: &Path, destination: &Path) -> io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
@@ -45,18 +27,6 @@ fn copy_backup_dir(source: &Path, destination: &Path) -> io::Result<()> {
Ok(())
}
-fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> {
- // Materialize links so a writable runtime copy does not depend on
- // read-only packaged resources (workspace node_modules, etc.).
- if source.is_dir() {
- copy_dir(source, destination)
- } else if source.is_file() {
- fs::copy(source, destination).map(|_| ())
- } else {
- Ok(())
- }
-}
-
fn overlay_symlink(source: &Path, destination: &Path) -> io::Result<()> {
if source.is_dir() {
overlay_dir(source, destination)
@@ -381,7 +351,7 @@ pub(crate) fn finish_failed_swap(
#[cfg(test)]
mod tests {
use super::{
- copy_dir, finish_failed_swap, overlay_dir, recover_interrupted_data_dir, recover_pointer,
+ finish_failed_swap, overlay_dir, recover_interrupted_data_dir, recover_pointer,
replace_dir, replace_dir_keep_previous, sync_writable_runtime,
};
use std::{
@@ -653,34 +623,6 @@ mod tests {
fs::remove_dir_all(&parent).ok();
}
- #[test]
- fn copy_dir_materializes_symlinks() {
- let parent = temp_parent();
- let source = parent.join("staged");
- let dest = parent.join("writable");
- fs::create_dir_all(source.join("packages")).expect("packages");
- fs::write(source.join("packages/app.txt"), "from-package").expect("package file");
- fs::create_dir_all(source.join("node_modules")).expect("node_modules");
- std::os::unix::fs::symlink(
- "../packages/app.txt",
- source.join("node_modules/app.txt"),
- )
- .expect("symlink");
-
- copy_dir(&source, &dest).expect("copy");
- assert_eq!(
- fs::read_to_string(dest.join("node_modules/app.txt")).expect("copied link"),
- "from-package"
- );
- assert!(
- fs::symlink_metadata(dest.join("node_modules/app.txt"))
- .expect("meta")
- .file_type()
- .is_file()
- );
- fs::remove_dir_all(&parent).ok();
- }
-
#[test]
fn sync_writable_runtime_copies_and_preserves_local_state() {
let parent = temp_parent();
diff --git a/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md b/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md
index c2eae38e..018baed9 100644
--- a/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md
+++ b/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md
@@ -7,5 +7,8 @@ This directory is the published `glib` 0.18.5 crate from
- Security advisory: `GHSA-wrw7-89jp-8q8g`
- Applied upstream fix: `gtk-rs/gtk-rs-core@44ff04449535135aa82507ff492883bba77a9b75`
- Local delta: the commit's exact two-line `VariantStrIter::impl_get` mutability patch in `src/variant_iter.rs`
+- Manifest delta: a `[lints.rust]` table allowing `mismatched_lifetime_syntaxes` and `unused_parens`.
+ A path patch is linted like our own code, so newer rustc lints on this 2023 source would
+ otherwise warn on every desktop build; registry crates get the same silence from `--cap-lints`.
The upstream `LICENSE` and `COPYRIGHT` files are preserved in the crate directory.
diff --git a/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml b/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml
index 132e07df..78ba39a2 100644
--- a/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml
+++ b/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml
@@ -183,3 +183,7 @@ v2_78 = [
"ffi/v2_78",
"gobject_ffi/v2_78",
]
+
+[lints.rust]
+mismatched_lifetime_syntaxes = "allow"
+unused_parens = "allow"
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 7a6c0964..3ef610f1 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -1 +1,2 @@
.vitest/
+__traces__/
diff --git a/apps/web/e2e/assistant/open-assistant.spec.ts b/apps/web/e2e/assistant/open-assistant.spec.ts
deleted file mode 100644
index c1aa6214..00000000
--- a/apps/web/e2e/assistant/open-assistant.spec.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import {
- completeOnboarding,
- createTrip,
- openGroamAssistant,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { test } from '@playwright/test';
-
-test('opens Groam AI on a trip with a ready prompt', async ({ page }) => {
- test.setTimeout(120_000);
- // A fresh user keeps the per-user AI thread rate limit out of the way.
- await completeOnboarding(page, { groupName: `Assistant ${uniqueSuffix()}` });
- await createTrip(page, { name: `Assistant ${uniqueSuffix()}` });
- await openGroamAssistant(page);
-});
diff --git a/apps/web/e2e/assistant/open-assistant.test.tsx b/apps/web/e2e/assistant/open-assistant.test.tsx
new file mode 100644
index 00000000..7dd10f3e
--- /dev/null
+++ b/apps/web/e2e/assistant/open-assistant.test.tsx
@@ -0,0 +1,18 @@
+import {
+ arrange,
+ assistantPanel,
+ openGroamAssistant,
+ openTrip,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('opens Groam AI on a trip with a ready prompt', async () => {
+ // Each journey file owns a fresh account, which keeps the per-user AI thread rate limit out of the way.
+ const tripId = await arrange.createTrip({ name: `Assistant ${uniqueSuffix()}` });
+ await openApp();
+ await openTrip(tripId);
+ await openGroamAssistant();
+ await expect.element(assistantPanel()).toBeVisible();
+});
diff --git a/apps/web/e2e/auth/account-security.spec.ts b/apps/web/e2e/auth/account-security.spec.ts
deleted file mode 100644
index 1f761f75..00000000
--- a/apps/web/e2e/auth/account-security.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- completeOnboarding,
- enableTwoFactorAndSignInWithBackupCode,
- openSettingsSecuritySection,
- recoverAccountWithDownloadedCode,
- registerPasskeyAndSignIn
-} from '@groam/app-actions/playwright';
-import { test } from '@playwright/test';
-
-const recoveredPassword = 'Recovered-password-1234';
-test.setTimeout(120_000);
-
-test('resets a forgotten password with a downloaded account recovery code', async ({ page }) => {
- const { user } = await completeOnboarding(page, { groupName: `Recovery ${Date.now()}` });
- await openSettingsSecuritySection(page);
- await recoverAccountWithDownloadedCode(page, user.username, recoveredPassword, user.password);
-});
-
-test('registers a passkey and signs in with the discoverable credential', async ({ page }) => {
- await completeOnboarding(page, { groupName: `Passkey ${Date.now()}` });
- await openSettingsSecuritySection(page);
- await registerPasskeyAndSignIn(page, 'Playwright authenticator');
-});
-
-test('enables an authenticator and signs in with a downloaded backup code', async ({ page }) => {
- const { user } = await completeOnboarding(page, { groupName: `Authenticator ${Date.now()}` });
- await openSettingsSecuritySection(page);
- await enableTwoFactorAndSignInWithBackupCode(page, user);
-});
diff --git a/apps/web/e2e/auth/account-security.test.tsx b/apps/web/e2e/auth/account-security.test.tsx
new file mode 100644
index 00000000..cb710ead
--- /dev/null
+++ b/apps/web/e2e/auth/account-security.test.tsx
@@ -0,0 +1,37 @@
+import {
+ by,
+ completeOnboarding,
+ ids,
+ enableTwoFactorAndSignInWithBackupCode,
+ openSettingsSecuritySection,
+ recoverAccountWithDownloadedCode,
+ registerPasskeyAndSignIn
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openSignedOutApp } from '@/testing/open-app';
+
+const recoveredPassword = 'Recovered-password-1234';
+
+test('resets a forgotten password with a downloaded account recovery code', async () => {
+ await openSignedOutApp();
+ const { user } = await completeOnboarding({ groupName: `Recovery ${Date.now()}` });
+ await openSettingsSecuritySection();
+ await recoverAccountWithDownloadedCode(user.username, recoveredPassword, user.password);
+ await expect.element(by(ids.navTrips)).toBeVisible();
+});
+
+test('registers a passkey and signs in with the discoverable credential', async () => {
+ await openSignedOutApp();
+ await completeOnboarding({ groupName: `Passkey ${Date.now()}` });
+ await openSettingsSecuritySection();
+ await registerPasskeyAndSignIn('Virtual authenticator');
+ await expect.element(by(ids.navTrips)).toBeVisible();
+});
+
+test('enables an authenticator and signs in with a downloaded backup code', async () => {
+ await openSignedOutApp();
+ const { user } = await completeOnboarding({ groupName: `Authenticator ${Date.now()}` });
+ await openSettingsSecuritySection();
+ await enableTwoFactorAndSignInWithBackupCode(user);
+ await expect.element(by(ids.navTrips)).toBeVisible();
+});
diff --git a/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts b/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts
deleted file mode 100644
index 55604e9b..00000000
--- a/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import {
- by,
- completeOnboarding,
- createFirstGroup,
- ids,
- signUp,
- uniqueTestUser
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('requires a typed group name before create group enables', async ({ page }) => {
- const user = uniqueTestUser('onboarding');
- await signUp(page, user);
-
- const createGroupButton = by(page, ids.onboardingCreateGroup);
- await expect(createGroupButton).toBeDisabled();
- await expect(by(page, ids.onboardingGroupName)).toHaveAttribute('placeholder', 'The Smiths');
- await createFirstGroup(page, `Typed Group ${Date.now()}`);
- await expect(by(page, ids.navTrips)).toBeVisible();
-});
-
-test('signs up and creates a first group', async ({ page }) => {
- const { groupName } = await completeOnboarding(page, {
- groupName: `Planning Crew ${Date.now()}`
- });
- await expect(by(page, ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName);
-});
diff --git a/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx b/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx
new file mode 100644
index 00000000..73e9708a
--- /dev/null
+++ b/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx
@@ -0,0 +1,26 @@
+import {
+ by,
+ completeOnboarding,
+ createFirstGroup,
+ ids,
+ signUp,
+ uniqueTestUser
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openSignedOutApp } from '@/testing/open-app';
+
+test('requires a typed group name before create group enables', async () => {
+ await openSignedOutApp();
+ await signUp(uniqueTestUser('onboarding'));
+
+ await expect.element(by(ids.onboardingCreateGroup)).toBeDisabled();
+ await expect.element(by(ids.onboardingGroupName)).toHaveAttribute('placeholder', 'The Smiths');
+ await createFirstGroup(`Typed Group ${Date.now()}`);
+ await expect.element(by(ids.navTrips)).toBeVisible();
+});
+
+test('signs up and creates a first group', async () => {
+ await openSignedOutApp();
+ const { groupName } = await completeOnboarding({ groupName: `Planning Crew ${Date.now()}` });
+ await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName);
+});
diff --git a/apps/web/e2e/chat/chat-reaction.spec.ts b/apps/web/e2e/chat/chat-reaction.spec.ts
deleted file mode 100644
index 25897daf..00000000
--- a/apps/web/e2e/chat/chat-reaction.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- addGroupMemberViaInvite,
- chatMessage,
- chatRow,
- createGroupChat,
- openChatInbox,
- reactToChatMessage,
- reloadAppPage,
- sendChatMessage,
- signIn,
- uniqueSuffix,
- uniqueTestUser
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('a chat reaction remains visible after a page reload', async ({ browser, page }) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const member = uniqueTestUser('reaction-member');
- await addGroupMemberViaInvite(browser, page, member);
- await openChatInbox(page);
- const title = `Reactions ${suffix}`;
- await createGroupChat(page, { memberName: member.name, title });
- const message = `A great plan ${suffix}`;
- await sendChatMessage(page, message);
- // The composer shows optimistic messages before the server confirms them.
- await expect(chatRow(page, title)).toContainText(message);
- await reloadAppPage(page);
- await expect(chatMessage(page, message)).toBeVisible();
- await reactToChatMessage(page, message, 'Agree');
- await reloadAppPage(page);
- await expect(
- chatMessage(page, message).getByRole('button', { name: 'Agree', exact: true })
- ).toBeVisible();
-});
diff --git a/apps/web/e2e/chat/chat-reaction.test.tsx b/apps/web/e2e/chat/chat-reaction.test.tsx
new file mode 100644
index 00000000..cc3ece34
--- /dev/null
+++ b/apps/web/e2e/chat/chat-reaction.test.tsx
@@ -0,0 +1,29 @@
+import {
+ arrange,
+ chatMessage,
+ chatRow,
+ createGroupChat,
+ reactToChatMessage,
+ reloadAppPage,
+ sendChatMessage,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('a chat reaction remains visible after a page reload', async () => {
+ const suffix = uniqueSuffix();
+ const member = await arrange.addMember('reaction-member');
+ await openApp();
+ const title = `Reactions ${suffix}`;
+ await createGroupChat({ memberName: member.name, title });
+ const message = `A great plan ${suffix}`;
+ await sendChatMessage(message);
+ // The composer shows optimistic messages before the server confirms them.
+ await expect.element(chatRow(title)).toMatchTextContent(message);
+ await reloadAppPage();
+ await expect.element(chatMessage(message)).toBeVisible();
+ await reactToChatMessage(message, 'Agree');
+ await reloadAppPage();
+ await expect.element(chatMessage(message).getByRole('button', { name: 'Agree' })).toBeVisible();
+});
diff --git a/apps/web/e2e/chat/chat-with-the-group.spec.ts b/apps/web/e2e/chat/chat-with-the-group.spec.ts
deleted file mode 100644
index c37a14f9..00000000
--- a/apps/web/e2e/chat/chat-with-the-group.spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import {
- addGroupMemberViaInvite,
- chatRow,
- createGroupChat,
- openChatInbox,
- sendMessage,
- signIn,
- uniqueSuffix,
- uniqueTestUser
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('chats with a group member', async ({ browser, page }) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const member = uniqueTestUser('chat-member');
- await addGroupMemberViaInvite(browser, page, member);
-
- await openChatInbox(page);
- await createGroupChat(page, { memberName: member.name, title: `Dates thread ${suffix}` });
- await sendMessage(page, { text: `Should we aim for late July? (${suffix})` });
- await openChatInbox(page);
- await expect(chatRow(page, `Dates thread ${suffix}`)).toBeVisible();
-});
diff --git a/apps/web/e2e/chat/chat-with-the-group.test.tsx b/apps/web/e2e/chat/chat-with-the-group.test.tsx
new file mode 100644
index 00000000..05c451ba
--- /dev/null
+++ b/apps/web/e2e/chat/chat-with-the-group.test.tsx
@@ -0,0 +1,21 @@
+import {
+ arrange,
+ chatRow,
+ createGroupChat,
+ openChatInbox,
+ sendChatMessage,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('chats with a group member', async () => {
+ const suffix = uniqueSuffix();
+ const member = await arrange.addMember('chat-member');
+ await openApp();
+
+ await createGroupChat({ memberName: member.name, title: `Dates thread ${suffix}` });
+ await sendChatMessage(`Should we aim for late July? (${suffix})`);
+ await openChatInbox();
+ await expect.element(chatRow(`Dates thread ${suffix}`)).toBeVisible();
+});
diff --git a/apps/web/e2e/group/invite-a-member.spec.ts b/apps/web/e2e/group/invite-a-member.spec.ts
deleted file mode 100644
index fcc017ba..00000000
--- a/apps/web/e2e/group/invite-a-member.spec.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import {
- acceptGroupInvitation,
- addIdea,
- by,
- createTrip,
- ids,
- inviteGroupMember,
- openTripFromList,
- openTrips,
- redeemGroupInvitationCode,
- signIn,
- tripCard,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('copies and pastes an invitation code that joins a member to the group', async ({
- browser,
- page
-}) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const member = uniqueTestUser('trip-member');
- const tripName = `Shared trip ${suffix}`;
-
- await createTrip(page, { name: tripName });
- const invitationCode = await inviteGroupMember(page);
-
- const memberContext = await browser.newContext();
- const memberPage = await memberContext.newPage();
- await acceptGroupInvitation(memberPage, invitationCode, member);
- await openTripFromList(memberPage, tripName);
- await expect(by(memberPage, ids.tripRole)).toHaveAttribute('data-role', 'participant');
- await expect(by(memberPage, ids.sharedTripBanner)).toBeVisible();
- await expect(by(memberPage, ids.sharedTripNewIdea)).toBeVisible();
- await expect(memberPage.getByRole('button', { name: 'Edit details' })).toHaveCount(0);
-
- await addIdea(memberPage, { name: `member/idea-${suffix}` });
- await updateTrip(memberPage, { dateNotes: 'First week of May' });
-
- await memberPage.close();
- await memberContext.close();
-
- await openTrips(page);
- await expect(tripCard(page, tripName)).toBeVisible();
-});
-
-test('opens the group gracefully when the code belongs to an existing membership', async ({
- page
-}) => {
- test.setTimeout(90_000);
- await signIn(page);
- const groupName = await by(page, ids.groupSwitcher).getAttribute('data-group-name');
- if (!groupName) throw new Error('Expected an active group before redeeming its invitation');
- const invitationCode = await inviteGroupMember(page);
-
- await redeemGroupInvitationCode(page, invitationCode);
-
- await expect(by(page, ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName);
- await expect(page.getByText('Unable to join group')).toHaveCount(0);
-});
diff --git a/apps/web/e2e/group/invite-a-member.test.tsx b/apps/web/e2e/group/invite-a-member.test.tsx
new file mode 100644
index 00000000..b92e040b
--- /dev/null
+++ b/apps/web/e2e/group/invite-a-member.test.tsx
@@ -0,0 +1,65 @@
+import {
+ acceptGroupInvitation,
+ addIdea,
+ arrange,
+ by,
+ environment,
+ expectGone,
+ ids,
+ inviteGroupMember,
+ openSettingsGroupSection,
+ openTripFromList,
+ redeemGroupInvitationCode,
+ reloadAppPage,
+ signUp,
+ uniqueSuffix,
+ uniqueTestUser,
+ updateTrip
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp, openSignedOutApp } from '@/testing/open-app';
+
+test('an invitation code copied from settings joins a new member to the group', async () => {
+ await openApp();
+ await environment.allowClipboard();
+ const invitationCode = await inviteGroupMember();
+
+ // The invited person's own screens are the next test; here they redeem the code in the backend.
+ const member = await arrange.redeemInvitation(invitationCode);
+ await reloadAppPage();
+ await openSettingsGroupSection();
+ await expect.element(page.getByText(member.name).first()).toBeVisible();
+});
+
+test('a member pastes an invitation code, joins the group and plans on its trips', async () => {
+ const suffix = uniqueSuffix();
+ const tripName = `Shared trip ${suffix}`;
+ const invitationCode = await arrange.createInvitation();
+ await arrange.createTrip({ name: tripName });
+
+ await openSignedOutApp();
+ await environment.allowClipboard();
+ await signUp(uniqueTestUser('trip-member'));
+ await acceptGroupInvitation(invitationCode);
+ await openTripFromList(tripName);
+ await expect.element(by(ids.tripRole)).toHaveAttribute('data-role', 'participant');
+ await expect.element(by(ids.sharedTripBanner)).toBeVisible();
+ await expect.element(by(ids.sharedTripNewIdea)).toBeVisible();
+ await expectGone(page.getByRole('button', { name: 'Edit details' }));
+
+ await addIdea({ name: `member/idea-${suffix}` });
+ await updateTrip({ dateNotes: 'First week of May' });
+});
+
+test('opens the group gracefully when the code belongs to an existing membership', async () => {
+ const { groupName } = await arrange.account();
+ await openApp();
+ await environment.allowClipboard();
+ const invitationCode = await inviteGroupMember();
+
+ await redeemGroupInvitationCode(invitationCode);
+
+ await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName);
+ await expectGone(page.getByText('Unable to join group'));
+});
diff --git a/apps/web/e2e/group/switch-groups.spec.ts b/apps/web/e2e/group/switch-groups.spec.ts
deleted file mode 100644
index 556de80b..00000000
--- a/apps/web/e2e/group/switch-groups.spec.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {
- by,
- createGroup,
- createTrip,
- deleteCurrentGroup,
- ids,
- openTrips,
- signIn,
- switchGroup,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('group switching isolates reactive Convex data', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const organizationName = `Isolation ${uniqueSuffix()}`;
- const tripName = `Only in ${organizationName}`;
-
- await createGroup(page, { name: organizationName });
-
- await createTrip(page, { name: tripName });
- await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
-
- await switchGroup(page, { name: 'Groam Demo' });
- await expect(tripCard(page, tripName)).toHaveCount(0);
-
- await switchGroup(page, { name: organizationName });
- await openTrips(page);
- await expect(tripCard(page, tripName)).toBeVisible();
-
- await deleteCurrentGroup(page, { fallbackGroupName: 'Groam Demo' });
-});
diff --git a/apps/web/e2e/group/switch-groups.test.tsx b/apps/web/e2e/group/switch-groups.test.tsx
new file mode 100644
index 00000000..52d020cb
--- /dev/null
+++ b/apps/web/e2e/group/switch-groups.test.tsx
@@ -0,0 +1,37 @@
+import {
+ arrange,
+ by,
+ createGroup,
+ createTripFromTrips,
+ deleteCurrentGroup,
+ expectGone,
+ ids,
+ openTrips,
+ switchGroup,
+ tripCard,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('group switching isolates reactive Convex data', async () => {
+ const { groupName: homeGroupName } = await arrange.account();
+ await openApp();
+ const organizationName = `Isolation ${uniqueSuffix()}`;
+ const tripName = `Only in ${organizationName}`;
+
+ await createGroup({ name: organizationName });
+
+ await createTripFromTrips({ name: tripName }, 'empty');
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
+
+ await switchGroup({ name: homeGroupName });
+ await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', homeGroupName);
+ await expectGone(tripCard(tripName));
+
+ await switchGroup({ name: organizationName });
+ await openTrips();
+ await expect.element(tripCard(tripName)).toBeVisible();
+
+ await deleteCurrentGroup({ fallbackGroupName: homeGroupName });
+});
diff --git a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
deleted file mode 100644
index 3a3fafbe..00000000
--- a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import {
- acceptGroupInvitation,
- addActivity,
- addIdea,
- addStop,
- applyIdea,
- approveIdea,
- createTrip,
- inviteGroupMember,
- mockDestinationSearch,
- openAppPath,
- openIdeaFromList,
- openItinerary,
- openSharedTrip,
- requestIdeaReview,
- saveSevenDayRange,
- signIn,
- uniqueSuffix,
- uniqueTestUser
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('an approved idea activity appears on the shared itinerary only after applying it', async ({
- browser,
- page
-}) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripId = await createTrip(page, { name: `Applied itinerary ${suffix}` });
- const ideaName = `Add a waterfront walk ${suffix}`;
- await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Porto', notes: 'River', startDay: '4', endDay: '5' });
- await addActivity(page, { destination: 'Porto, Portugal', title: 'Waterfront walk' });
- await openSharedTrip(page);
- await openAppPath(page, `trips/${tripId}/itinerary`);
- await expect(page.getByRole('region', { name: 'Day planner' })).toBeVisible();
- await expect(page.getByRole('region', { name: 'Day planner' })).not.toContainText(
- 'Waterfront walk'
- );
- await openIdeaFromList(page, ideaName);
- await requestIdeaReview(page);
-
- const code = await inviteGroupMember(page);
- const reviewerContext = await browser.newContext();
- try {
- const reviewer = await reviewerContext.newPage();
- await acceptGroupInvitation(reviewer, code, uniqueTestUser('activity-reviewer'));
- await openIdeaFromList(reviewer, ideaName);
- await approveIdea(reviewer);
- } finally {
- await reviewerContext.close();
- }
-
- await openAppPath(page, `trips/${tripId}/itinerary`);
- await expect(page.getByRole('region', { name: 'Day planner' })).toBeVisible();
- await expect(page.getByRole('region', { name: 'Day planner' })).not.toContainText(
- 'Waterfront walk'
- );
- await openIdeaFromList(page, ideaName);
- await applyIdea(page);
- await openSharedTrip(page);
- await openAppPath(page, `trips/${tripId}/itinerary`);
- await expect(page).toHaveURL(new RegExp(`/trips/${tripId}/itinerary`, 'u'));
- await expect(page.getByRole('region', { name: 'Day planner' })).toContainText('Waterfront walk');
-});
diff --git a/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx b/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx
new file mode 100644
index 00000000..6982d80b
--- /dev/null
+++ b/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx
@@ -0,0 +1,52 @@
+import {
+ addActivity,
+ addStop,
+ applyIdea,
+ arrange,
+ environment,
+ expectPath,
+ openAppPath,
+ openIdea,
+ openIdeaFromList,
+ openSharedTrip,
+ requestIdeaReview,
+ saveSevenDayRange,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('an approved idea activity appears on the shared itinerary only after applying it', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const reviewer = await arrange.addMember('activity-reviewer');
+ const sharedTripId = await arrange.createTrip({ name: `Applied itinerary ${suffix}` });
+ const ideaName = `Add a waterfront walk ${suffix}`;
+ const idea = await arrange.startIdea({ name: ideaName, tripId: sharedTripId });
+ await openApp();
+ const { tripId } = await openIdea({ proposalId: idea.proposalId, tripId: sharedTripId });
+ await saveSevenDayRange();
+ await addStop({ first: true, name: 'Porto', notes: 'River', startDay: '4', endDay: '5' });
+ await addActivity({ destination: 'Porto, Portugal', title: 'Waterfront walk' });
+ await openSharedTrip();
+ await openAppPath(`trips/${tripId}/itinerary`);
+ const planner = page.getByRole('region', { name: 'Day planner' });
+ await expect.element(planner).toBeVisible();
+ await expect.element(planner).not.toMatchTextContent('Waterfront walk');
+ await openIdeaFromList({ title: ideaName }, 'continue');
+ await requestIdeaReview();
+
+ // A second person reviews: their own screens are covered by view tests, so they act through the backend.
+ await arrange.approveIdea({ ideaName, memberEmail: reviewer.email, tripId: sharedTripId });
+
+ await openAppPath(`trips/${tripId}/itinerary`);
+ await expect.element(planner).toBeVisible();
+ await expect.element(planner).not.toMatchTextContent('Waterfront walk');
+ await openIdeaFromList({ title: ideaName }, 'view');
+ await applyIdea();
+ await openSharedTrip();
+ await openAppPath(`trips/${tripId}/itinerary`);
+ await expectPath(new RegExp(`/trips/${tripId}/itinerary`, 'u'));
+ await expect.element(planner).toMatchTextContent('Waterfront walk');
+});
diff --git a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts b/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
deleted file mode 100644
index 7f80bed8..00000000
--- a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import {
- addActivity,
- addIdea,
- addStop,
- createTrip,
- discardActivityChanges,
- ids,
- inlineActivityEditor,
- keepActivityPlan,
- mockDestinationSearch,
- openActivityPlan,
- openIdeaFromList,
- openIdeaItinerary,
- openItinerary,
- saveSevenDayRange,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('cancelling edits or removal leaves the saved idea activity intact', async ({ page }) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Cancel edits ${suffix}` });
- const ideaName = `Keep the plan ${suffix}`;
- await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Porto', notes: 'Riverside', startDay: '1', endDay: '3' });
- await addActivity(page, {
- destination: 'Porto, Portugal',
- address: 'Original meeting point',
- title: 'Boat trip'
- });
-
- await discardActivityChanges(page, 'Boat trip', 'Unsaved meeting point');
- await keepActivityPlan(page, 'Boat trip');
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await openActivityPlan(page, 'Boat trip');
- await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue(
- 'Original meeting point'
- );
-});
diff --git a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
deleted file mode 100644
index 90095f5f..00000000
--- a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import {
- addActivity,
- addIdea,
- addStop,
- continueActivityPlan,
- createIssue,
- createTrip,
- editActivity,
- ids,
- inlineActivityEditor,
- mockDestinationSearch,
- openActivityPlan,
- openIdeaComparison,
- openIdeaFromList,
- openIdeaItinerary,
- openItinerary,
- openSharedTrip,
- openTripSection,
- removeActivityPlan,
- requestIdeaReview,
- returnIdeaToEditing,
- saveSevenDayRange,
- signIn,
- startActivityPlan,
- startIdeaFromIssue,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('adds, edits and removes activities on a later-day idea from both entry points', async ({
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const ideaName = `Later-day activities ${suffix}`;
- await createTrip(page, { name: `Later-day trip ${suffix}` });
- await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '5' });
-
- // Days 1–3 have no destination yet, so planning starts on Day 4.
- await startActivityPlan(page);
- const editor = inlineActivityEditor(page);
- await expect(editor).toHaveAttribute('aria-label', 'Add an activity to Day 4');
- await continueActivityPlan(page, 'Porto river walk');
- await expect(editor).toHaveCount(0);
- await expect(page.getByRole('button', { name: 'Edit Porto river walk' })).toBeVisible();
-
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await editActivity(page, { title: 'Porto river walk', address: 'Ribeira, Porto' });
- await openActivityPlan(page, 'Porto river walk');
- await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue(
- 'Ribeira, Porto'
- );
- await removeActivityPlan(page, 'Porto river walk');
- await expect(page.getByRole('button', { name: 'Edit Porto river walk' })).toHaveCount(0);
-
- await addActivity(page, { destination: 'Porto, Portugal', title: 'Evening cruise' });
- await expect(page.getByRole('button', { name: 'Edit Evening cruise' })).toBeVisible();
-});
-
-test('switches destination and day, returns from comparison, and submits activity changes for review', async ({
- page
-}) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const ideaName = `Review activity ${suffix}`;
- await createTrip(page, { name: `Review itinerary ${suffix}` });
- await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '3' });
- await addStop(page, { name: 'Porto', notes: 'River', startDay: '4', endDay: '5' });
-
- await startActivityPlan(page, 5);
- await expect(inlineActivityEditor(page)).toContainText('Day 5 · Porto, Portugal');
- await continueActivityPlan(page, 'Cruise on the Douro');
- await expect(page.getByRole('region', { name: 'Day 5 schedule' })).toContainText(
- 'Cruise on the Douro'
- );
-
- await openIdeaComparison(page);
- await expect(page.getByTestId(ids.compareSharedTrip)).toContainText('Cruise on the Douro');
- await returnIdeaToEditing(page);
- await editActivity(page, { title: 'Cruise on the Douro', address: 'Cais da Ribeira' });
- await openIdeaFromList(page, ideaName);
- await openIdeaComparison(page);
- await requestIdeaReview(page);
- // Authors may still amend their working copy during review.
- await openIdeaFromList(page, ideaName);
- await expect(page.getByTestId(ids.ideaPrimaryAction)).toHaveText('Approve');
- await openIdeaItinerary(page);
- await editActivity(page, { title: 'Cruise on the Douro', address: 'Cais da Ribeira 42' });
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await openActivityPlan(page, 'Cruise on the Douro');
- await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue(
- 'Cais da Ribeira 42'
- );
-});
-
-test('adds an activity to an issue-linked idea without changing the shared trip', async ({
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripId = await createTrip(page, { name: `Issue itinerary ${suffix}` });
- await createIssue(page, {
- title: `Plan a walk ${suffix}`,
- body: 'Add a day out to the trip',
- tripId
- });
- const ideaName = await startIdeaFromIssue(page);
- await saveSevenDayRange(page);
- await openIdeaItinerary(page);
- await addStop(page, { name: 'Lisbon', notes: 'Walk', startDay: '1', endDay: '3' });
- await addActivity(page, { destination: 'Lisbon, Portugal', title: 'Walk through Alfama' });
- await openSharedTrip(page);
- await openTripSection(page, 'itinerary');
- await expect(page.getByText('Walk through Alfama')).toHaveCount(0);
- await openTripSection(page, 'ideas');
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await expect(page.getByRole('button', { name: 'Edit Walk through Alfama' })).toBeVisible();
-});
diff --git a/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx b/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx
new file mode 100644
index 00000000..688d86c5
--- /dev/null
+++ b/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx
@@ -0,0 +1,90 @@
+import {
+ addActivity,
+ addStop,
+ arrange,
+ by,
+ continueActivityPlan,
+ editActivity,
+ environment,
+ expectGone,
+ ids,
+ openAppPath,
+ openIdea,
+ openIdeaComparison,
+ openIdeaFromList,
+ openIdeaItinerary,
+ openIssue,
+ openSharedTrip,
+ openTripSection,
+ requestIdeaReview,
+ returnIdeaToEditing,
+ saveSevenDayRange,
+ startActivityPlan,
+ startIdeaFromIssue,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+// Component-level editor states (later-day entry points, add/edit/remove, address round trips) are
+// left to view tests; these journeys keep the flows that cross pages.
+test('plans an activity on an idea, compares it, and submits the change for review', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const ideaName = `Review activity ${suffix}`;
+ const tripId = await arrange.createTrip({ name: `Review itinerary ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openApp();
+ await openIdea({ proposalId, tripId });
+ await saveSevenDayRange();
+ await addStop({ first: true, name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '3' });
+ await addStop({ name: 'Porto', notes: 'River', startDay: '4', endDay: '5' });
+
+ await startActivityPlan(5);
+ await continueActivityPlan('Cruise on the Douro');
+ await expect
+ .element(page.getByRole('region', { name: 'Day 5 schedule' }))
+ .toMatchTextContent('Cruise on the Douro');
+
+ await openIdeaComparison();
+ await expect.element(by(ids.compareSharedTrip)).toMatchTextContent('Cruise on the Douro');
+ await returnIdeaToEditing();
+ await editActivity({ title: 'Cruise on the Douro', address: 'Cais da Ribeira' });
+ await openIdeaFromList({ title: ideaName }, 'continue');
+ await openIdeaComparison();
+ await requestIdeaReview();
+ // Authors may still amend their working copy during review.
+ await openIdeaFromList({ title: ideaName }, 'view');
+ await expect.element(by(ids.ideaPrimaryAction)).toMatchTextContent('Approve');
+ await openIdeaItinerary();
+ await editActivity({ title: 'Cruise on the Douro', address: 'Cais da Ribeira 42' });
+});
+
+test('adds an activity to an issue-linked idea without changing the shared trip', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Issue itinerary ${suffix}` });
+ const issueId = await arrange.createIssue({
+ title: `Plan a walk ${suffix}`,
+ body: 'Add a day out to the trip',
+ tripId
+ });
+ await openApp();
+ await openIssue(issueId);
+ const ideaName = await startIdeaFromIssue();
+ await saveSevenDayRange();
+ await openIdeaItinerary();
+ await addStop({ first: true, name: 'Lisbon', notes: 'Walk', startDay: '1', endDay: '3' });
+ await addActivity({ destination: 'Lisbon, Portugal', title: 'Walk through Alfama' });
+ await openSharedTrip();
+ await openAppPath(`trips/${tripId}/itinerary`);
+ await expect.element(page.getByRole('region', { name: 'Day planner' })).toBeVisible();
+ await expectGone(page.getByText('Walk through Alfama'));
+ await openTripSection('ideas');
+ await openIdeaFromList({ name: ideaName }, 'continue');
+ await openIdeaItinerary();
+ await expect
+ .element(page.getByRole('button', { name: 'Edit Walk through Alfama' }))
+ .toBeVisible();
+});
diff --git a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
deleted file mode 100644
index 07c93fd1..00000000
--- a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
- addIdea,
- addStop,
- continueActivityPlan,
- createTrip,
- ids,
- inlineActivityEditor,
- mockDestinationSearch,
- openActivityPlan,
- openItinerary,
- saveSevenDayRange,
- signIn,
- startActivityPlan,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('editing from a continuation day shows the complete activity range', async ({ page }) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Multi-day plan ${suffix}` });
- await addIdea(page, { name: `Multi-day idea ${suffix}` });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '6' });
- await startActivityPlan(page);
- await continueActivityPlan(page, 'Three-day festival', { startDay: 4, endDay: 6 });
- await expect(page.getByRole('region', { name: 'Day 6 schedule' })).toContainText(
- 'Three-day festival'
- );
- await openActivityPlan(page, 'Three-day festival', 6);
- const editor = inlineActivityEditor(page);
- await expect(editor).toContainText('Days 4–6 · Porto, Portugal');
- await expect(editor.getByTestId(ids.activityTitle)).toHaveValue('Three-day festival');
- await expect(editor.getByTestId(ids.activitySubmit)).toBeEnabled();
-});
diff --git a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
deleted file mode 100644
index 0b1cfad2..00000000
--- a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import {
- addIdea,
- addInlineActivity,
- addStop,
- createTrip,
- editInlineActivity,
- ids,
- mockDestinationSearch,
- openItinerary,
- saveSevenDayRange,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('plans morning and afternoon activities directly within an idea day', async ({ page }) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Inline itinerary ${suffix}` });
- await addIdea(page, { name: `Plan this day ${suffix}` });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Lisbon', notes: 'Explore the city', startDay: '1', endDay: '3' });
-
- const day = page.getByRole('region', { name: 'Day 2 schedule' });
- await expect(day.getByRole('button', { name: 'Add to day' })).toBeVisible();
- await addInlineActivity(page, {
- day: 2,
- notes: 'Meet beside the bakery',
- period: 'morning',
- title: 'Market breakfast'
- });
- await addInlineActivity(page, { day: 2, period: 'afternoon', title: 'Riverside stroll' });
-
- // The day schedule lists the morning plan before the afternoon plan.
- const plans = day.getByRole('button', { name: /^Edit (?:Market breakfast|Riverside stroll)$/u });
- await expect(plans).toHaveCount(2);
- await expect(plans.first()).toHaveAccessibleName('Edit Market breakfast');
- await expect(plans.last()).toHaveAccessibleName('Edit Riverside stroll');
- await editInlineActivity(page, {
- address: 'Ribeira das Naus, Lisbon',
- day: 2,
- period: 'afternoon',
- title: 'Riverside stroll'
- });
- await expect(day.getByRole('button', { name: 'Edit Riverside stroll' })).toBeVisible();
- await expect(page.getByTestId(ids.activityInlineEditor)).toHaveCount(0);
-});
-
-test('switching a shared day’s destination keeps the inline activity draft', async ({ page }) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Shared day ${suffix}` });
- await addIdea(page, { name: `Two places ${suffix}` });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '2' });
- await addStop(page, { name: 'Porto', notes: 'River', startDay: '2', endDay: '4' });
-
- await addInlineActivity(page, {
- day: 2,
- destination: 'Porto, Portugal',
- expectDayReset: true,
- notes: 'Meet by the old bridge',
- period: 'afternoon',
- range: { startDay: 1, endDay: 2 },
- title: 'Riverside picnic'
- });
- const sharedDay = page.getByRole('region', { name: 'Day 2 schedule' });
- // The saved plan's row names the destination chosen in the inline draft.
- await expect(
- sharedDay
- .locator('[data-slot="timeline-item"]')
- .filter({ has: page.getByRole('button', { name: 'Edit Riverside picnic' }) })
- ).toContainText('Porto');
-});
-
-test('mobile day planning keeps the inline activity form in the schedule', async ({ page }) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Mobile day plan ${suffix}` });
- await addIdea(page, { name: `Mobile activities ${suffix}` });
- await saveSevenDayRange(page);
- await openItinerary(page);
- await addStop(page, { name: 'Porto', notes: 'River', startDay: '2', endDay: '3' });
- await page.setViewportSize({ width: 390, height: 844 });
-
- const day = page.getByRole('region', { name: 'Day 2 schedule' });
- await expect(day.getByRole('button', { name: 'Add to day' })).toBeVisible();
- await addInlineActivity(page, { day: 2, period: 'morning', title: 'Morning market' });
- await expect(day.getByRole('button', { name: 'Edit Morning market' })).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
-});
diff --git a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
deleted file mode 100644
index 66946af1..00000000
--- a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import {
- addActivity,
- addFirstDestination,
- addIdea,
- createTrip,
- editActivity,
- mockDestinationSearch,
- openIdeaFromList,
- openIdeaItinerary,
- openItinerary,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('plans and revisits an activity before trip dates or destination days are set', async ({
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Flexible trip ${suffix}` });
- const ideaName = `Plan before dates ${suffix}`;
- await addIdea(page, { name: ideaName });
- await openItinerary(page);
- await addFirstDestination(page, { name: 'Porto', notes: 'River walk' });
- await addActivity(page, { destination: 'Porto, Portugal', title: 'Explore the old town' });
- await expect(page.getByRole('region', { name: 'Day 1 schedule' })).toContainText(
- 'Explore the old town'
- );
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await editActivity(page, { title: 'Explore the old town', address: 'Ribeira, Porto' });
-});
diff --git a/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx b/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx
new file mode 100644
index 00000000..0abfd7c6
--- /dev/null
+++ b/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx
@@ -0,0 +1,34 @@
+import {
+ addActivity,
+ addFirstDestination,
+ arrange,
+ editActivity,
+ environment,
+ openIdea,
+ openIdeaFromList,
+ openTripSection,
+ openIdeaItinerary,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('plans and revisits an activity before trip dates or destination days are set', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Flexible trip ${suffix}` });
+ const ideaName = `Plan before dates ${suffix}`;
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openApp();
+ await openIdea({ proposalId, tripId });
+ await openTripSection('itinerary');
+ await addFirstDestination({ name: 'Porto', notes: 'River walk' });
+ await addActivity({ destination: 'Porto, Portugal', title: 'Explore the old town' });
+ await expect
+ .element(page.getByRole('region', { name: 'Day 1 schedule' }))
+ .toMatchTextContent('Explore the old town');
+ await openIdeaFromList({ title: ideaName }, 'continue');
+ await openIdeaItinerary();
+ await editActivity({ title: 'Explore the old town', address: 'Ribeira, Porto' });
+});
diff --git a/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts b/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts
deleted file mode 100644
index 7699b63b..00000000
--- a/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { ids, openDestinationDetails } from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-for (const label of [
- 'Open Porto, Portugal details for Day 1',
- 'Manage Porto, Portugal for Day 1'
-]) {
- test(`opens destination details from ${label}`, async ({ page }) => {
- await page.setContent(`
-
-
- `);
- await page.evaluate(() => {
- document.querySelector('button')?.addEventListener('click', () => {
- document.querySelector('section')?.removeAttribute('hidden');
- });
- });
-
- await openDestinationDetails(page, 'Porto, Portugal');
-
- await expect(page.getByTestId(ids.destinationCard)).toBeVisible();
- });
-}
diff --git a/apps/web/e2e/ideas/planning/idea-layout.spec.ts b/apps/web/e2e/ideas/planning/idea-layout.spec.ts
deleted file mode 100644
index 21b53619..00000000
--- a/apps/web/e2e/ideas/planning/idea-layout.spec.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import {
- addIdea,
- by,
- createTrip,
- ids,
- openIdeaComparison,
- openIdeaFromList,
- openTripSection,
- openWorkspaceIdeas,
- pageCanvas,
- resolvedBackground,
- signIn,
- twoColumnPageLayout,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('idea tabs share the page canvas while itinerary uses its full width', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- await createTrip(page, { name: `Layout defaults ${uniqueSuffix()}` });
- const ideaName = `Consistent tabs ${uniqueSuffix()}`;
- await addIdea(page, { name: ideaName });
- await openWorkspaceIdeas(page);
- const sidebarLink = await by(page, ids.navTrips).elementHandle();
- if (!sidebarLink) throw new Error('Expected workspace navigation');
- await openIdeaFromList(page, ideaName);
- await expect(by(page, ids.tripHeading)).toContainText(ideaName);
- expect(await sidebarLink.evaluate((element) => element.isConnected)).toBe(true);
-
- // Trip pages size their supporting column from the trip workspace container (18rem).
- const tripRightColumn = 288;
- for (const width of [1440, 1024, 390]) {
- await page.setViewportSize({ width, height: 1000 });
- await openTripSection(page, 'overview');
- const overview = await twoColumnPageLayout(page);
- const canvas = await pageCanvas(page);
- expect(overview.overflow).toBe(false);
- expect(overview.stacked).toBe(width < 1280);
- if (width >= 1280) expect(overview.rightWidth).toBe(tripRightColumn);
- await openTripSection(page, 'itinerary');
- // The itinerary editor is a single full-width section without a supporting column.
- await expect(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
- width
- );
- const itineraryWidth = await page.locator('[data-slot="shell-page-body"]').evaluate((body) => {
- const section = body.querySelector('section[aria-label="Itinerary editor"]');
- if (!section) throw new Error('Expected the itinerary content section');
- const style = getComputedStyle(body);
- return {
- available: Math.round(
- body.clientWidth -
- Number.parseFloat(style.paddingLeft) -
- Number.parseFloat(style.paddingRight)
- ),
- content: Math.round(section.getBoundingClientRect().width)
- };
- });
- expect(itineraryWidth.content).toBe(itineraryWidth.available);
- await expect.poll(() => pageCanvas(page)).toEqual(canvas);
-
- await openIdeaComparison(page);
- await expect.poll(() => twoColumnPageLayout(page)).toEqual(overview);
- await expect.poll(() => pageCanvas(page)).toEqual(canvas);
- }
-});
-
-test('all shared trip tabs retain the Overview canvas', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- await createTrip(page, { name: `Trip canvas ${uniqueSuffix()}` });
- for (const dark of [false, true]) {
- await page.evaluate(
- (enabled) => document.documentElement.classList.toggle('dark', enabled),
- dark
- );
- for (const width of [1440, 390]) {
- await page.setViewportSize({ width, height: 1000 });
- await openTripSection(page, 'overview');
- const overview = await pageCanvas(page);
- expect(overview.overflow).toBe(false);
- expect(overview.background).toBe(await resolvedBackground(page, 'var(--canvas)'));
- await expect(page.locator('[data-slot=sidebar-wrapper]')).toHaveCSS(
- 'background-color',
- await resolvedBackground(page, 'transparent')
- );
- for (const tab of ['issues', 'ideas', 'activity'] as const) {
- await openTripSection(page, tab);
- await expect.poll(() => pageCanvas(page)).toEqual(overview);
- }
- }
- }
-});
diff --git a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
deleted file mode 100644
index b4f0249e..00000000
--- a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import {
- addActivity,
- addIdea,
- addStop,
- createTrip,
- editActivity,
- mockDestinationSearch,
- openItinerary,
- openTrips,
- saveSevenDayRange,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('plans a trip itinerary on an idea working copy', async ({ page }) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripName = `Itinerary ${suffix}`;
- const ideaName = `dates/seven-day-plan-${suffix}`;
-
- await createTrip(page, { name: tripName });
- await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
-
- await updateTrip(page, { budgetAmount: 1800 });
-
- await openItinerary(page);
- await addStop(page, { endDay: '3', name: 'Lisbon', notes: 'Food and old town', startDay: '1' });
- await addStop(page, { endDay: '5', name: 'Porto', notes: 'River and cellars', startDay: '4' });
-
- await addActivity(page, {
- destination: 'Lisbon, Portugal',
- address: 'Miradouro da Senhora do Monte, Lisbon, Portugal',
- notes: 'Meet by the tram stop',
- title: 'Sunset viewpoint walk'
- });
- await addActivity(page, {
- destination: 'Lisbon, Portugal',
- address: 'Rua da Bica 18, Lisbon, Portugal',
- title: 'Dinner reservation'
- });
-
- await editActivity(page, {
- address: 'Miradouro da Senhora do Monte, 1170-107 Lisbon, Portugal',
- title: 'Sunset viewpoint walk'
- });
- await expect(page.getByRole('tablist', { name: 'Planning tools' })).toHaveCount(0);
- await expect(page.getByRole('button', { name: 'Choose a day' })).toHaveCount(0);
- await expect(page.getByRole('region', { name: 'Day 1 schedule', exact: true })).toBeVisible();
- await expect(page.getByRole('region', { name: 'Day 7 schedule', exact: true })).toBeVisible();
- await openTrips(page);
-});
diff --git a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx
new file mode 100644
index 00000000..4883c47a
--- /dev/null
+++ b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx
@@ -0,0 +1,57 @@
+import {
+ addActivity,
+ addStop,
+ arrange,
+ editActivity,
+ environment,
+ openIdea,
+ openTrips,
+ saveSevenDayRange,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('plans a trip itinerary on an idea working copy', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const tripName = `Itinerary ${suffix}`;
+ const ideaName = `dates/seven-day-plan-${suffix}`;
+
+ const tripId = await arrange.createTrip({ name: tripName });
+ const idea = await arrange.startIdea({ name: ideaName, tripId });
+ await arrange.updateTrip({ budgetAmount: 1800, tripId: idea.workingTripId });
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ await saveSevenDayRange();
+
+ await addStop({
+ endDay: '3',
+ first: true,
+ name: 'Lisbon',
+ notes: 'Food and old town',
+ startDay: '1'
+ });
+ await addStop({ endDay: '5', name: 'Porto', notes: 'River and cellars', startDay: '4' });
+
+ await addActivity({
+ destination: 'Lisbon, Portugal',
+ address: 'Miradouro da Senhora do Monte, Lisbon, Portugal',
+ notes: 'Meet by the tram stop',
+ title: 'Sunset viewpoint walk'
+ });
+ await addActivity({
+ destination: 'Lisbon, Portugal',
+ address: 'Rua da Bica 18, Lisbon, Portugal',
+ title: 'Dinner reservation'
+ });
+
+ await editActivity({
+ address: 'Miradouro da Senhora do Monte, 1170-107 Lisbon, Portugal',
+ title: 'Sunset viewpoint walk'
+ });
+ await expect.element(page.getByRole('region', { name: 'Day 1 schedule' })).toBeVisible();
+ await expect.element(page.getByRole('region', { name: 'Day 7 schedule' })).toBeVisible();
+ await openTrips();
+});
diff --git a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts b/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
deleted file mode 100644
index 99b2b986..00000000
--- a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import {
- addIdea,
- by,
- createTrip,
- ideaRow,
- ids,
- openIdeaFromList,
- openSharedTrip,
- openTripSection,
- openWorkspaceIdeas,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('starts an idea and opens first-class idea routes', async ({ page }) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripName = `Idea routes ${suffix}`;
- const ideaName = `routes/open-idea-${suffix}`;
-
- const tripId = await createTrip(page, { name: tripName });
- await addIdea(page, { name: ideaName });
- await openSharedTrip(page);
- await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
- await openTripSection(page, 'ideas');
-
- const draftRow = ideaRow(page, ideaName);
- await expect(draftRow.getByTestId(ids.ideaContinue)).toBeVisible();
- const proposalId = await draftRow.getAttribute('data-idea-id');
- expect(proposalId).toBeTruthy();
-
- await openWorkspaceIdeas(page);
- await expect(ideaRow(page, ideaName)).toBeVisible();
-
- await openIdeaFromList(page, ideaName);
- await expect(page).toHaveURL(
- new RegExp(`/trips/${tripId}/ideas/[a-z]{6}/overview(?:\\?.*)?$`, 'u')
- );
-});
diff --git a/apps/web/e2e/ideas/planning/start-an-idea.test.tsx b/apps/web/e2e/ideas/planning/start-an-idea.test.tsx
new file mode 100644
index 00000000..5ab062a4
--- /dev/null
+++ b/apps/web/e2e/ideas/planning/start-an-idea.test.tsx
@@ -0,0 +1,40 @@
+import {
+ addIdea,
+ arrange,
+ by,
+ expectPath,
+ ideaRow,
+ ids,
+ openIdeaFromList,
+ openSharedTrip,
+ openTrip,
+ openTripSection,
+ openWorkspaceIdeas,
+ presentAttribute,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('starts an idea and opens first-class idea routes', async () => {
+ const suffix = uniqueSuffix();
+ const tripName = `Idea routes ${suffix}`;
+ const ideaName = `routes/open-idea-${suffix}`;
+
+ await openApp();
+ const tripId = await openTrip(await arrange.createTrip({ name: tripName }));
+ await addIdea({ name: ideaName });
+ await openSharedTrip();
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
+ await openTripSection('ideas');
+
+ const draftRow = ideaRow({ title: ideaName });
+ await expect.element(by(ids.ideaContinue, draftRow)).toBeVisible();
+ await presentAttribute(draftRow, 'data-idea-id');
+
+ await openWorkspaceIdeas();
+ await expect.element(ideaRow({ title: ideaName })).toBeVisible();
+
+ await openIdeaFromList({ title: ideaName }, 'continue');
+ await expectPath(new RegExp(`/trips/${tripId}/ideas/[a-z]{6}/overview(?:\\?.*)?$`, 'u'));
+});
diff --git a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts b/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
deleted file mode 100644
index ea405e03..00000000
--- a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import {
- by,
- createTrip,
- ids,
- openSharedTrip,
- signIn,
- startWorkspaceIdea,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('starts a new idea from workspace Ideas for the selected trip', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const firstName = `Unselected trip ${suffix}`;
- const selectedName = `Selected trip ${suffix}`;
- await createTrip(page, { name: firstName });
- const selectedTripId = await createTrip(page, { name: selectedName });
- const ideaName = `Change the selected trip ${suffix}`;
-
- await startWorkspaceIdea(page, { tripName: selectedName, title: ideaName });
- await expect(page).toHaveURL(new RegExp(`/trips/${selectedTripId}/ideas/[^/]+/overview`, 'u'));
- await expect(page.getByRole('heading', { name: ideaName })).toBeVisible();
- await expect(by(page, ids.ideaWorkspace)).toHaveAttribute('data-idea-name', /.+/u);
- await openSharedTrip(page);
- await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', selectedName);
-});
diff --git a/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx b/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx
new file mode 100644
index 00000000..8541c8c4
--- /dev/null
+++ b/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx
@@ -0,0 +1,29 @@
+import {
+ arrange,
+ by,
+ expectPath,
+ ids,
+ openSharedTrip,
+ startWorkspaceIdea,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('starts a new idea from workspace Ideas for the selected trip', async () => {
+ const suffix = uniqueSuffix();
+ const firstName = `Unselected trip ${suffix}`;
+ const selectedName = `Selected trip ${suffix}`;
+ await arrange.createTrip({ name: firstName });
+ await arrange.createTrip({ name: selectedName });
+ await openApp();
+ const ideaName = `Change the selected trip ${suffix}`;
+
+ await startWorkspaceIdea({ tripName: selectedName, title: ideaName });
+ // The shared trip check below proves the idea belongs to the selected trip.
+ await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/overview/u);
+ await expect.element(page.getByRole('heading', { name: ideaName })).toBeVisible();
+ await openSharedTrip();
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', selectedName);
+});
diff --git a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts b/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
deleted file mode 100644
index 1222ba20..00000000
--- a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import {
- addFirstDestination,
- addIdea,
- by,
- createTrip,
- ids,
- mockDestinationSearch,
- openDestinationDetails,
- openTripSection,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('starts an idea and adds the first destination through one composer', async ({ page }) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await signIn(page);
- await createTrip(page, { name: `First destination ${uniqueSuffix()}` });
- await expect(by(page, ids.tripActionAddStop)).toHaveCount(0);
- await addIdea(page, { name: `First stop idea ${uniqueSuffix()}` });
- await openTripSection(page, 'itinerary');
-
- await expect(by(page, ids.startIdeaDialog)).toHaveCount(0);
- await expect(page).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u, {
- timeout: 20_000
- });
- await addFirstDestination(page, {
- initialSearch: 'Lisbon',
- name: 'Porto',
- notes: 'Food and old town'
- });
- await openDestinationDetails(page, 'Porto, Portugal');
- await expect(page).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u);
-});
diff --git a/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx b/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx
new file mode 100644
index 00000000..35716368
--- /dev/null
+++ b/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx
@@ -0,0 +1,37 @@
+import {
+ addFirstDestination,
+ addIdea,
+ arrange,
+ by,
+ destinationCard,
+ environment,
+ expectGone,
+ expectPath,
+ ids,
+ openDestinationDetails,
+ openTrip,
+ openTripSection,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('starts an idea and adds the first destination through one composer', async () => {
+ await environment.mockDestinationSearch();
+ await openApp();
+ await openTrip(await arrange.createTrip({ name: `First destination ${uniqueSuffix()}` }));
+ await expectGone(by(ids.tripActionAddStop));
+ await addIdea({ name: `First stop idea ${uniqueSuffix()}` });
+ await openTripSection('itinerary');
+
+ await expectGone(by(ids.startIdeaDialog));
+ await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u);
+ await addFirstDestination({
+ initialSearch: 'Lisbon',
+ name: 'Porto',
+ notes: 'Food and old town'
+ });
+ await openDestinationDetails('Porto, Portugal');
+ await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u);
+ await expect.element(destinationCard('Porto, Portugal')).toBeVisible();
+});
diff --git a/apps/web/e2e/ideas/review/close-idea.spec.ts b/apps/web/e2e/ideas/review/close-idea.spec.ts
deleted file mode 100644
index 4358d2a3..00000000
--- a/apps/web/e2e/ideas/review/close-idea.spec.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- addIdea,
- closeIdea,
- createTrip,
- filterWorkspaceIdeas,
- ideaRow,
- ids,
- requestIdeaReview,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('deletes a draft idea without leaving an editable working copy', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Abandoned trip ${suffix}` });
- const name = `Abandoned idea ${suffix}`;
- await addIdea(page, { name });
- await closeIdea(page);
- await filterWorkspaceIdeas(page, 'Settled', [name]);
- const row = ideaRow(page, name);
- await expect(row).toHaveAttribute('data-status', 'closed');
- await expect(row.getByTestId(ids.ideaContinue)).toHaveCount(0);
-});
-
-test('requires a reason to pass on an idea already in review', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Review trip ${suffix}` });
- const name = `Pass on idea ${suffix}`;
- await addIdea(page, { name });
- await updateTrip(page, { dateNotes: 'Try a different week' });
- await requestIdeaReview(page);
- await closeIdea(page, 'The original week works better');
- await filterWorkspaceIdeas(page, 'Settled', [name]);
- const row = ideaRow(page, name);
- await expect(row).toHaveAttribute('data-status', 'closed');
- await expect(row.getByTestId(ids.ideaContinue)).toHaveCount(0);
-});
diff --git a/apps/web/e2e/ideas/review/close-idea.test.tsx b/apps/web/e2e/ideas/review/close-idea.test.tsx
new file mode 100644
index 00000000..1a35e878
--- /dev/null
+++ b/apps/web/e2e/ideas/review/close-idea.test.tsx
@@ -0,0 +1,43 @@
+import {
+ arrange,
+ by,
+ closeIdea,
+ filterWorkspaceIdeas,
+ ideaRow,
+ ids,
+ expectGone,
+ openIdea,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('deletes a draft idea without leaving an editable working copy', async () => {
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Abandoned trip ${suffix}` });
+ const name = `Abandoned idea ${suffix}`;
+ const { proposalId } = await arrange.startIdea({ name, tripId });
+ await openApp();
+ await openIdea({ proposalId, tripId });
+ await closeIdea();
+ await filterWorkspaceIdeas('Settled', [name]);
+ const row = ideaRow({ title: name });
+ await expect.element(row).toHaveAttribute('data-status', 'closed');
+ await expectGone(by(ids.ideaContinue, row));
+});
+
+test('requires a reason to pass on an idea already in review', async () => {
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Review trip ${suffix}` });
+ const name = `Pass on idea ${suffix}`;
+ const idea = await arrange.startIdea({ name, tripId });
+ await arrange.updateTrip({ dateNotes: 'Try a different week', tripId: idea.workingTripId });
+ await arrange.requestIdeaReview(idea.proposalId);
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ await closeIdea('The original week works better');
+ await filterWorkspaceIdeas('Settled', [name]);
+ const row = ideaRow({ title: name });
+ await expect.element(row).toHaveAttribute('data-status', 'closed');
+ await expectGone(by(ids.ideaContinue, row));
+});
diff --git a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts b/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
deleted file mode 100644
index 861338c9..00000000
--- a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- addIdea,
- by,
- createTrip,
- ids,
- reloadAppPage,
- requestIdeaReview,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('submitting an idea for review does not allow applying it without approval', async ({
- page
-}) => {
- test.setTimeout(90_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Unapproved trip ${suffix}` });
- await addIdea(page, { name: `Unapproved idea ${suffix}` });
- await updateTrip(page, { dateNotes: 'A slower weekend' });
- await requestIdeaReview(page);
-
- await expect(by(page, ids.applyIdea).first()).toBeVisible();
- await expect(by(page, ids.applyIdea).first()).toBeDisabled();
- await reloadAppPage(page);
- await expect(by(page, ids.applyIdea).first()).toBeDisabled();
-});
diff --git a/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx b/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx
new file mode 100644
index 00000000..78b2ef6e
--- /dev/null
+++ b/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx
@@ -0,0 +1,27 @@
+import {
+ arrange,
+ by,
+ ids,
+ openIdea,
+ openIdeaComparison,
+ reloadAppPage,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('submitting an idea for review does not allow applying it without approval', async () => {
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Unapproved trip ${suffix}` });
+ const idea = await arrange.startIdea({ name: `Unapproved idea ${suffix}`, tripId });
+ await arrange.updateTrip({ dateNotes: 'A slower weekend', tripId: idea.workingTripId });
+ await arrange.requestIdeaReview(idea.proposalId);
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ await openIdeaComparison();
+
+ await expect.element(by(ids.applyIdea).first()).toBeVisible();
+ await expect.element(by(ids.applyIdea).first()).toBeDisabled();
+ await reloadAppPage();
+ await expect.element(by(ids.applyIdea).first()).toBeDisabled();
+});
diff --git a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts b/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
deleted file mode 100644
index 93ba6f4b..00000000
--- a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import {
- addIdea,
- by,
- createTrip,
- ids,
- openIdeaComparison,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('an empty idea cannot be submitted until it contains a change', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Empty proposal ${suffix}` });
- await addIdea(page, { name: `Fill this idea ${suffix}` });
- await openIdeaComparison(page);
- await expect(by(page, ids.requestReview).first()).toBeDisabled();
- await expect(page.getByText('Add a change before you can send this to the group.')).toBeVisible();
-
- await updateTrip(page, { dateNotes: 'Travel after the holidays' });
- await openIdeaComparison(page);
- await expect(by(page, ids.requestReview).first()).toBeEnabled();
-});
diff --git a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts b/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
deleted file mode 100644
index d58b44df..00000000
--- a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {
- addIdea,
- by,
- createTrip,
- ids,
- returnIdeaToEditing,
- reviewIdeaChange,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('reviews an edit, comments without blocking it, and returns to editing on mobile', async ({
- page
-}) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- await createTrip(page, { name: `Review polish ${suffix}` });
- await addIdea(page, { name: `Review dates ${suffix}` });
- const note = `Prefer a relaxed weekend ${suffix}`;
- await updateTrip(page, { dateNotes: note });
- const comment = `Looks good, keeping this flexible. ${suffix}`;
- await reviewIdeaChange(page, { change: note, comment });
- await expect(by(page, ids.requestReview)).toBeEnabled();
- await page.setViewportSize({ width: 390, height: 844 });
- await returnIdeaToEditing(page);
- expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
-});
diff --git a/apps/web/e2e/ideas/review/review-idea-changes.test.tsx b/apps/web/e2e/ideas/review/review-idea-changes.test.tsx
new file mode 100644
index 00000000..96d2e9e0
--- /dev/null
+++ b/apps/web/e2e/ideas/review/review-idea-changes.test.tsx
@@ -0,0 +1,27 @@
+import {
+ arrange,
+ by,
+ ids,
+ openIdea,
+ returnIdeaToEditing,
+ reviewIdeaChange,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+// Collapsing and expanding a change card, and the mobile no-overflow check, are component-level
+// and left to view tests.
+test('reviews an edit, comments without blocking it, and returns to editing', async () => {
+ const suffix = uniqueSuffix();
+ const tripId = await arrange.createTrip({ name: `Review polish ${suffix}` });
+ const idea = await arrange.startIdea({ name: `Review dates ${suffix}`, tripId });
+ const note = `Prefer a relaxed weekend ${suffix}`;
+ await arrange.updateTrip({ dateNotes: note, tripId: idea.workingTripId });
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ const comment = `Looks good, keeping this flexible. ${suffix}`;
+ await reviewIdeaChange({ change: note, comment });
+ await expect.element(by(ids.requestReview)).toBeEnabled();
+ await returnIdeaToEditing();
+});
diff --git a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts b/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
deleted file mode 100644
index 5fe8e7e6..00000000
--- a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- acceptGroupInvitation,
- addActivity,
- addIdea,
- addStop,
- approveIdea,
- createTrip,
- ids,
- inviteGroupMember,
- mockDestinationSearch,
- openAppPath,
- openIdeaComparison,
- openIdeaFromList,
- openIdeaItinerary,
- openTripSection,
- requestIdeaReview,
- saveSevenDayRange,
- signIn,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('reviewers can inspect and approve an idea but cannot edit its working copy', async ({
- browser,
- page
-}) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripId = await createTrip(page, { name: `Read-only review ${suffix}` });
- const ideaName = `Review-only idea ${suffix}`;
- const proposalId = await addIdea(page, { name: ideaName });
- await saveSevenDayRange(page);
- await openTripSection(page, 'overview');
- await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '1', endDay: '3' });
- await addActivity(page, { destination: 'Porto, Portugal', title: 'Riverside walk' });
- await updateTrip(page, { dateNotes: 'Prefer more time on the coast' });
- await requestIdeaReview(page);
-
- const code = await inviteGroupMember(page);
- const reviewerContext = await browser.newContext();
- try {
- const reviewer = await reviewerContext.newPage();
- await acceptGroupInvitation(reviewer, code, uniqueTestUser('reviewer'));
- await openIdeaFromList(reviewer, ideaName);
- await expect(reviewer.getByTestId(ids.ideaPrimaryAction)).toHaveText('Approve');
- await expect(reviewer.getByTestId(ids.tripActionEdit)).toHaveCount(0);
- await expect(reviewer.getByTestId(ids.ideaSectionCompare)).toBeVisible();
- // A direct itinerary URL must not expose the editor to a reviewer either.
- await openAppPath(reviewer, `trips/${tripId}/ideas/${proposalId}/itinerary`);
- await expect(reviewer.getByText('Riverside walk', { exact: true })).toBeVisible();
- await expect(reviewer.getByRole('region', { name: 'Itinerary editor' })).toHaveCount(0);
- await expect(reviewer.getByRole('button', { name: 'Add a plan' })).toHaveCount(0);
- await expect(
- reviewer.getByRole('button', { name: 'Add destination', exact: true })
- ).toHaveCount(0);
- await expect(reviewer.getByRole('button', { name: 'Add to day', exact: true })).toHaveCount(0);
- await expect(reviewer.getByRole('button', { name: 'Edit Riverside walk' })).toHaveCount(0);
- await openIdeaComparison(reviewer);
- await approveIdea(reviewer);
- await expect(reviewer.getByTestId(ids.approveIdea).first()).toHaveText(/Remove approval/u);
-
- await openIdeaFromList(page, ideaName);
- await openIdeaItinerary(page);
- await expect(page.getByRole('button', { name: 'Edit Riverside walk' })).toBeVisible();
- } finally {
- await reviewerContext.close();
- }
-});
diff --git a/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx b/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx
new file mode 100644
index 00000000..b12c88f5
--- /dev/null
+++ b/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx
@@ -0,0 +1,63 @@
+import {
+ addActivity,
+ addStop,
+ approveIdea,
+ arrange,
+ by,
+ environment,
+ expectGone,
+ ids,
+ openAppPath,
+ openIdea,
+ openIdeaComparison,
+ openIdeaFromList,
+ openIdeaItinerary,
+ openTripSection,
+ requestIdeaReview,
+ saveSevenDayRange,
+ uniqueSuffix,
+ updateTrip
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('reviewers can inspect and approve an idea but cannot edit its working copy', async () => {
+ await environment.mockDestinationSearch();
+ const suffix = uniqueSuffix();
+ const reviewer = await arrange.addMember('reviewer');
+ const tripId = await arrange.createTrip({ name: `Read-only review ${suffix}` });
+ const ideaName = `Review-only idea ${suffix}`;
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openApp();
+ await openIdea({ proposalId, tripId });
+ await saveSevenDayRange();
+ await openTripSection('overview');
+ await addStop({ first: true, name: 'Porto', notes: 'River walk', startDay: '1', endDay: '3' });
+ await addActivity({ destination: 'Porto, Portugal', title: 'Riverside walk' });
+ await updateTrip({ dateNotes: 'Prefer more time on the coast' });
+ await requestIdeaReview();
+
+ // The same browser now belongs to the reviewer, one person at a time.
+ await openApp('/trips', { as: reviewer });
+ await openIdeaFromList({ title: ideaName }, 'view');
+ await expect.element(by(ids.ideaPrimaryAction)).toMatchTextContent('Approve');
+ await expectGone(by(ids.tripActionEdit));
+ await expect.element(by(ids.ideaSectionCompare)).toBeVisible();
+ // A direct itinerary URL must not expose the editor to a reviewer either.
+ await openAppPath(`trips/${tripId}/ideas/${proposalId}/itinerary`);
+ await expect.element(page.getByText('Riverside walk')).toBeVisible();
+ await expectGone(page.getByRole('region', { name: 'Itinerary editor' }));
+ await expectGone(page.getByRole('button', { name: 'Add a plan' }));
+ await expectGone(page.getByRole('button', { name: 'Add destination' }));
+ await expectGone(page.getByRole('button', { name: 'Add to day' }));
+ await expectGone(page.getByRole('button', { name: 'Edit Riverside walk' }));
+ await openIdeaComparison();
+ await approveIdea();
+ await expect.element(by(ids.approveIdea).first()).toMatchTextContent(/Remove approval/u);
+
+ await openApp();
+ await openIdeaFromList({ title: ideaName }, 'view');
+ await openIdeaItinerary();
+ await expect.element(page.getByRole('button', { name: 'Edit Riverside walk' })).toBeVisible();
+});
diff --git a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts b/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
deleted file mode 100644
index e7040ee1..00000000
--- a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ids, openBackgroundActivity, signIn } from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('background activity is discoverable in settings and opens a real run', async ({ page }) => {
- await signIn(page);
- await expect(page.getByRole('complementary').getByTestId(ids.navAgents)).toHaveCount(0);
- const result = await openBackgroundActivity(page);
- if (result === 'empty') {
- await expect(page.getByText('Nothing running yet')).toBeVisible();
- }
- await page.screenshot({ path: 'test-results/background-activity.png' });
-});
diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
deleted file mode 100644
index 998fdfd2..00000000
--- a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import {
- acceptGroupInvitation,
- addIdea,
- approveIdea,
- by,
- createTrip,
- ids,
- inviteGroupMember,
- openIdeaFromList,
- requestIdeaReview,
- revokeIdeaApproval,
- signIn,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('revoking the only approval prevents an idea from being applied', async ({
- browser,
- page
-}) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const ideaName = `Approval reversal ${suffix}`;
- await createTrip(page, { name: `Review reversal ${suffix}` });
- await addIdea(page, { name: ideaName });
- await updateTrip(page, { dateNotes: 'More time by the river' });
- await requestIdeaReview(page);
-
- const code = await inviteGroupMember(page);
- const reviewerContext = await browser.newContext();
- try {
- const reviewer = await reviewerContext.newPage();
- await acceptGroupInvitation(reviewer, code, uniqueTestUser('approval-reversal'));
- await openIdeaFromList(reviewer, ideaName);
- await approveIdea(reviewer);
- await openIdeaFromList(page, ideaName);
- await expect(by(page, ids.applyIdea).first()).toBeEnabled();
-
- await revokeIdeaApproval(reviewer);
- await expect(by(reviewer, ids.approveIdea).first()).toHaveText(/Approve/u);
- await expect(by(page, ids.applyIdea).first()).toBeDisabled();
-
- await approveIdea(reviewer);
- await expect(by(page, ids.applyIdea).first()).toBeEnabled();
- } finally {
- await reviewerContext.close();
- }
-});
diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx b/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx
new file mode 100644
index 00000000..aa2efac1
--- /dev/null
+++ b/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx
@@ -0,0 +1,37 @@
+import {
+ arrange,
+ by,
+ ids,
+ openIdea,
+ openIdeaComparison,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('revoking the only approval prevents an idea from being applied', async () => {
+ const suffix = uniqueSuffix();
+ const ideaName = `Approval reversal ${suffix}`;
+ const tripId = await arrange.createTrip({ name: `Review reversal ${suffix}` });
+ const idea = await arrange.startIdea({ name: ideaName, tripId });
+ await arrange.updateTrip({ dateNotes: 'More time by the river', tripId: idea.workingTripId });
+ await arrange.requestIdeaReview(idea.proposalId);
+ const reviewer = await arrange.addMember('approval-reversal');
+ const review = { ideaName, memberEmail: reviewer.email, tripId };
+
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ await openIdeaComparison();
+ await expect.element(by(ids.applyIdea).first()).toBeDisabled();
+
+ // The reviewer's own screen is covered by view tests; they act through the backend and the
+ // author's screen reacts live.
+ await arrange.approveIdea(review);
+ await expect.element(by(ids.applyIdea).first()).toBeEnabled();
+
+ await arrange.approveIdea({ ...review, approved: false });
+ await expect.element(by(ids.applyIdea).first()).toBeDisabled();
+
+ await arrange.approveIdea(review);
+ await expect.element(by(ids.applyIdea).first()).toBeEnabled();
+});
diff --git a/apps/web/e2e/issues/create-an-issue.spec.ts b/apps/web/e2e/issues/create-an-issue.spec.ts
deleted file mode 100644
index 12a51c3e..00000000
--- a/apps/web/e2e/issues/create-an-issue.spec.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import {
- by,
- createIssue,
- createPlaywrightActions,
- createTrip,
- filterIssuesByStatus,
- ids,
- issueRow,
- openAppPath,
- openIssueFromInbox,
- openIssuesInbox,
- openTripSection,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('creates a trip issue and opens it as a first-class page', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripName = `Issue trip ${suffix}`;
- const issueTitle = `Rainy-day plan ${suffix}`;
- const issueBody = 'Add a backup indoor day if it rains.';
- const app = createPlaywrightActions();
-
- const tripId = await createTrip(page, { name: tripName });
- const issueId = await createIssue(page, { body: issueBody, title: issueTitle, tripId });
- const issueRecordId = await by(page, ids.issueHeading).getAttribute('data-issue-id');
- if (!issueRecordId) throw new Error('Created issue did not expose its record ID');
-
- await app.addIssueComment(page, { content: `Need a museum option (${suffix})`, issueId });
-
- await app.assignIssue(page, { assignee: { kind: 'agent' }, issueId });
- await expect(by(page, ids.issueToggleStatus)).toBeEnabled();
-
- await app.setIssueStatus(page, { issueId, status: 'closed' });
-
- await openIssuesInbox(page);
- await filterIssuesByStatus(page, 'closed');
- const workspaceIssueLink = issueRow(page, issueTitle).locator('a').first();
- await expect(workspaceIssueLink).toHaveAttribute('href', `/issues/${issueId}`);
- await openIssueFromInbox(page, issueTitle, { status: 'closed' });
- await expect(page).toHaveURL(new RegExp(`/issues/${issueId}`, 'u'));
- await expect(by(page, ids.issueHeading)).toHaveAttribute('data-issue-id', issueRecordId);
-
- await openAppPath(page, `trips/${tripId}/issues`);
- await expect(page).toHaveURL(new RegExp(`/trips/${tripId}/issues`, 'u'));
- await openTripSection(page, 'issues');
- await filterIssuesByStatus(page, 'closed');
- await expect(issueRow(page, issueTitle).locator('a').first()).toHaveAttribute(
- 'href',
- `/issues/${issueId}`
- );
-});
-
-test('lists workspace issues from the sidebar', async ({ page }) => {
- await signIn(page);
- await openIssuesInbox(page);
- await expect(by(page, ids.issuesTitle)).toBeVisible();
-});
diff --git a/apps/web/e2e/issues/create-an-issue.test.tsx b/apps/web/e2e/issues/create-an-issue.test.tsx
new file mode 100644
index 00000000..94f16722
--- /dev/null
+++ b/apps/web/e2e/issues/create-an-issue.test.tsx
@@ -0,0 +1,60 @@
+import {
+ addIssueComment,
+ arrange,
+ assignIssue,
+ by,
+ createIssue,
+ expectPath,
+ filterIssuesByStatus,
+ ids,
+ issueRow,
+ openAppPath,
+ openIssueFromInbox,
+ openIssuesInbox,
+ openTrip,
+ openTripSection,
+ presentAttribute,
+ setIssueStatus,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('creates a trip issue and opens it as a first-class page', async () => {
+ const suffix = uniqueSuffix();
+ const tripName = `Issue trip ${suffix}`;
+ const issueTitle = `Rainy-day plan ${suffix}`;
+ const issueBody = 'Add a backup indoor day if it rains.';
+
+ const { credentials } = await arrange.account();
+ await openApp();
+ const tripId = await openTrip(await arrange.createTrip({ name: tripName }));
+ const issueId = await createIssue({ body: issueBody, title: issueTitle });
+ const issueRecordId = await presentAttribute(by(ids.issueHeading), 'data-issue-id');
+
+ await addIssueComment(`Need a museum option (${suffix})`);
+
+ // Assigning a traveler keeps the flow independent of AI keys; Groam assignment is covered by
+ // the assignee picker's view tests and the backend's agent-run tests.
+ await assignIssue({ kind: 'user', name: credentials.name });
+ await expect.element(by(ids.issueToggleStatus)).toBeEnabled();
+
+ await setIssueStatus('closed');
+
+ await openIssuesInbox();
+ await filterIssuesByStatus('closed');
+ await expect
+ .element(issueRow(issueTitle).getByRole('link').first())
+ .toHaveAttribute('href', `/issues/${issueId}`);
+ await openIssueFromInbox(issueTitle, { status: 'closed' });
+ await expectPath(new RegExp(`/issues/${issueId}`, 'u'));
+ await expect.element(by(ids.issueHeading)).toHaveAttribute('data-issue-id', issueRecordId);
+
+ await openAppPath(`trips/${tripId}/issues`);
+ await expectPath(new RegExp(`/trips/${tripId}/issues`, 'u'));
+ await openTripSection('issues');
+ await filterIssuesByStatus('closed');
+ await expect
+ .element(issueRow(issueTitle).getByRole('link').first())
+ .toHaveAttribute('href', `/issues/${issueId}`);
+});
diff --git a/apps/web/e2e/issues/issue-comment-persistence.spec.ts b/apps/web/e2e/issues/issue-comment-persistence.spec.ts
deleted file mode 100644
index a788d55c..00000000
--- a/apps/web/e2e/issues/issue-comment-persistence.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- by,
- createIssue,
- createPlaywrightActions,
- createTrip,
- ids,
- openIssueFromInbox,
- openIssueFromTrip,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('issue comments persist across trip and workspace views', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const title = `Issue conversation ${suffix}`;
- const comment = `Check the museum schedule ${suffix}`;
- const tripId = await createTrip(page, { name: `Comment trip ${suffix}` });
- const issueId = await createIssue(page, { body: 'Need an indoor alternative.', title, tripId });
- await createPlaywrightActions().addIssueComment(page, { content: comment, issueId });
- await expect(by(page, ids.issueComment)).toBeEmpty();
-
- await openIssueFromTrip(page, tripId, title);
- await expect(page.getByText(comment, { exact: true })).toBeVisible();
- await openIssueFromInbox(page, title);
- await expect(page.getByText(comment, { exact: true })).toBeVisible();
-});
diff --git a/apps/web/e2e/issues/issue-comment-persistence.test.tsx b/apps/web/e2e/issues/issue-comment-persistence.test.tsx
new file mode 100644
index 00000000..ec4771a8
--- /dev/null
+++ b/apps/web/e2e/issues/issue-comment-persistence.test.tsx
@@ -0,0 +1,30 @@
+import {
+ addIssueComment,
+ arrange,
+ by,
+ ids,
+ openIssue,
+ openIssueFromInbox,
+ openIssueFromTrip,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('issue comments persist across trip and workspace views', async () => {
+ const suffix = uniqueSuffix();
+ const title = `Issue conversation ${suffix}`;
+ const comment = `Check the museum schedule ${suffix}`;
+ const tripId = await arrange.createTrip({ name: `Comment trip ${suffix}` });
+ const issueId = await arrange.createIssue({ body: 'Need an indoor alternative.', title, tripId });
+ await openApp();
+ await openIssue(issueId);
+ await addIssueComment(comment);
+ await expect.element(by(ids.issueComment)).toHaveValue('');
+
+ await openIssueFromTrip(tripId, title);
+ await expect.element(page.getByText(comment)).toBeVisible();
+ await openIssueFromInbox(title);
+ await expect.element(page.getByText(comment)).toBeVisible();
+});
diff --git a/apps/web/e2e/issues/reopen-issue.spec.ts b/apps/web/e2e/issues/reopen-issue.spec.ts
deleted file mode 100644
index c8036a03..00000000
--- a/apps/web/e2e/issues/reopen-issue.spec.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import {
- by,
- createIssue,
- createTrip,
- filterIssuesByStatus,
- ids,
- issueRow,
- openIssueFromInbox,
- openIssuesInbox,
- setIssueStatus,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('reopening a closed issue moves it back into the open workspace filter', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const title = `Reopen rainy-day plan ${suffix}`;
- const tripId = await createTrip(page, { name: `Issue reopen trip ${suffix}` });
- const issueId = await createIssue(page, { body: 'Check indoor alternatives.', title, tripId });
- await setIssueStatus(page, { issueId, status: 'closed' });
-
- await openIssuesInbox(page);
- await filterIssuesByStatus(page, 'closed');
- await expect(issueRow(page, title)).toBeVisible();
- await openIssueFromInbox(page, title, { status: 'closed' });
- await setIssueStatus(page, { issueId, status: 'open' });
- await expect(by(page, ids.issueStatus)).toHaveAttribute('data-status', 'open');
-
- await openIssuesInbox(page);
- await filterIssuesByStatus(page, 'open');
- await expect(issueRow(page, title)).toBeVisible();
- await filterIssuesByStatus(page, 'closed');
- await expect(issueRow(page, title)).toHaveCount(0);
-});
diff --git a/apps/web/e2e/issues/reopen-issue.test.tsx b/apps/web/e2e/issues/reopen-issue.test.tsx
new file mode 100644
index 00000000..0d14b762
--- /dev/null
+++ b/apps/web/e2e/issues/reopen-issue.test.tsx
@@ -0,0 +1,38 @@
+import {
+ arrange,
+ by,
+ expectGone,
+ filterIssuesByStatus,
+ ids,
+ issueRow,
+ openIssue,
+ openIssueFromInbox,
+ openIssuesInbox,
+ setIssueStatus,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('reopening a closed issue moves it back into the open workspace filter', async () => {
+ const suffix = uniqueSuffix();
+ const title = `Reopen rainy-day plan ${suffix}`;
+ const tripId = await arrange.createTrip({ name: `Issue reopen trip ${suffix}` });
+ const issueId = await arrange.createIssue({ body: 'Check indoor alternatives.', title, tripId });
+ await openApp();
+ await openIssue(issueId);
+ await setIssueStatus('closed');
+
+ await openIssuesInbox();
+ await filterIssuesByStatus('closed');
+ await expect.element(issueRow(title)).toBeVisible();
+ await openIssueFromInbox(title, { status: 'closed' });
+ await setIssueStatus('open');
+ await expect.element(by(ids.issueStatus)).toHaveAttribute('data-status', 'open');
+
+ await openIssuesInbox();
+ await filterIssuesByStatus('open');
+ await expect.element(issueRow(title)).toBeVisible();
+ await filterIssuesByStatus('closed');
+ await expectGone(issueRow(title));
+});
diff --git a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts
deleted file mode 100644
index 5332432b..00000000
--- a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
- acceptGroupInvitation,
- applyIdea,
- approveIdea,
- by,
- createIssue,
- createTrip,
- idFromPath,
- ids,
- inviteGroupMember,
- openAppPath,
- openIdeaFromList,
- requestIdeaReview,
- signIn,
- startIdeaFromIssue,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('creates a trip, makes an issue, and resolves it with a merged idea', async ({
- browser,
- page
-}) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const member = uniqueTestUser('approver');
-
- const tripId = await createTrip(page, { name: `Merge trip ${suffix}` });
- const issueId = await createIssue(page, {
- body: 'If it rains we need a museum day.',
- title: `Need indoor backup ${suffix}`,
- tripId
- });
-
- const ideaName = await startIdeaFromIssue(page);
-
- await updateTrip(page, { dateNotes: 'Indoor museum day if it rains' });
- await requestIdeaReview(page);
-
- const invitationCode = await inviteGroupMember(page);
- const memberContext = await browser.newContext();
- const memberPage = await memberContext.newPage();
- await acceptGroupInvitation(memberPage, invitationCode, member);
- await openIdeaFromList(memberPage, ideaName);
- await expect(memberPage).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+/u, { timeout: 20_000 });
- await approveIdea(memberPage);
- await memberPage.close();
- await memberContext.close();
-
- await page.bringToFront();
- await openIdeaFromList(page, ideaName);
- await applyIdea(page);
-
- await openAppPath(page, `issues/${issueId}`);
- await expect(by(page, ids.issueStatus)).toHaveAttribute('data-status', 'closed');
- expect(idFromPath(page, 'issues')).toBe(issueId);
-});
diff --git a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx
new file mode 100644
index 00000000..59718e54
--- /dev/null
+++ b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx
@@ -0,0 +1,45 @@
+import {
+ applyIdea,
+ arrange,
+ by,
+ idFromPath,
+ ids,
+ openAppPath,
+ openIdeaFromList,
+ openIssue,
+ requestIdeaReview,
+ startIdeaFromIssue,
+ uniqueSuffix,
+ updateTrip
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('creates a trip, makes an issue, and resolves it with a merged idea', async () => {
+ const suffix = uniqueSuffix();
+ const member = await arrange.addMember('approver');
+ const tripId = await arrange.createTrip({ name: `Merge trip ${suffix}` });
+ await openApp();
+ const issueId = await openIssue(
+ await arrange.createIssue({
+ body: 'If it rains we need a museum day.',
+ title: `Need indoor backup ${suffix}`,
+ tripId
+ })
+ );
+
+ const ideaName = await startIdeaFromIssue();
+
+ await updateTrip({ dateNotes: 'Indoor museum day if it rains' });
+ await requestIdeaReview();
+
+ // A group member approves; their own screens are covered by view tests, so they use the backend.
+ await arrange.approveIdea({ ideaName, memberEmail: member.email, tripId });
+
+ await openIdeaFromList({ name: ideaName }, 'view');
+ await applyIdea();
+
+ await openAppPath(`issues/${issueId}`);
+ await expect.element(by(ids.issueStatus)).toHaveAttribute('data-status', 'closed');
+ expect(idFromPath('issues')).toBe(issueId);
+});
diff --git a/apps/web/e2e/trips/archive-trip-filter.spec.ts b/apps/web/e2e/trips/archive-trip-filter.spec.ts
deleted file mode 100644
index 05030b60..00000000
--- a/apps/web/e2e/trips/archive-trip-filter.spec.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {
- archiveTrip,
- createTrip,
- filterTripsByStatus,
- openAppPath,
- openTripFromList,
- openTrips,
- restoreTrip,
- signIn,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('archived trips leave the active list and return after restoring', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const tripName = `Archive filter ${uniqueSuffix()}`;
- await createTrip(page, { name: tripName });
- await archiveTrip(page);
-
- await openAppPath(page, 'trips');
- await filterTripsByStatus(page, 'active');
- await expect(tripCard(page, tripName)).toHaveCount(0);
- await filterTripsByStatus(page, 'archived');
- await expect(tripCard(page, tripName)).toBeVisible();
-
- await openTripFromList(page, tripName);
- await restoreTrip(page);
- await openTrips(page);
- await filterTripsByStatus(page, 'active');
- await expect(tripCard(page, tripName)).toBeVisible();
-});
diff --git a/apps/web/e2e/trips/archive-trip-filter.test.tsx b/apps/web/e2e/trips/archive-trip-filter.test.tsx
new file mode 100644
index 00000000..a281b09d
--- /dev/null
+++ b/apps/web/e2e/trips/archive-trip-filter.test.tsx
@@ -0,0 +1,34 @@
+import {
+ archiveTrip,
+ arrange,
+ expectGone,
+ filterTripsByStatus,
+ openAppPath,
+ openTrip,
+ openTripFromList,
+ openTrips,
+ restoreTrip,
+ tripCard,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('archived trips leave the active list and return after restoring', async () => {
+ const tripName = `Archive filter ${uniqueSuffix()}`;
+ await openApp();
+ await openTrip(await arrange.createTrip({ name: tripName }));
+ await archiveTrip();
+
+ await openAppPath('trips');
+ await filterTripsByStatus('active');
+ await expectGone(tripCard(tripName));
+ await filterTripsByStatus('archived');
+ await expect.element(tripCard(tripName)).toBeVisible();
+
+ await openTripFromList(tripName);
+ await restoreTrip();
+ await openTrips();
+ await filterTripsByStatus('active');
+ await expect.element(tripCard(tripName)).toBeVisible();
+});
diff --git a/apps/web/e2e/trips/create-a-trip.spec.ts b/apps/web/e2e/trips/create-a-trip.spec.ts
deleted file mode 100644
index e88298ec..00000000
--- a/apps/web/e2e/trips/create-a-trip.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- archiveTrip,
- by,
- createTrip,
- ids,
- openTrips,
- restoreTrip,
- signIn,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('creates, archives, restores, and lists a destination-optional trip', async ({ page }) => {
- test.setTimeout(90_000);
- await signIn(page);
- const tripName = `Beach idea ${uniqueSuffix()}`;
-
- await createTrip(page, {
- dateNotes: 'Late July',
- durationDays: 8,
- name: tripName
- });
- await expect(by(page, ids.tripDestination)).toHaveAttribute(
- 'data-destination-status',
- 'undecided'
- );
- await expect(by(page, ids.tripRole)).toHaveAttribute('data-role', 'organizer');
-
- await archiveTrip(page);
- await restoreTrip(page);
- await expect(by(page, ids.tripArchive)).toBeVisible();
-
- await openTrips(page);
- await expect(tripCard(page, tripName)).toBeVisible();
-});
diff --git a/apps/web/e2e/trips/create-a-trip.test.tsx b/apps/web/e2e/trips/create-a-trip.test.tsx
new file mode 100644
index 00000000..436fbd7a
--- /dev/null
+++ b/apps/web/e2e/trips/create-a-trip.test.tsx
@@ -0,0 +1,30 @@
+import {
+ archiveTrip,
+ by,
+ createTripFromTrips,
+ ids,
+ openTrips,
+ restoreTrip,
+ tripCard,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('creates, archives, restores, and lists a destination-optional trip', async () => {
+ await openApp();
+ const tripName = `Beach idea ${uniqueSuffix()}`;
+
+ await createTripFromTrips({ dateNotes: 'Late July', durationDays: 8, name: tripName }, 'empty');
+ await expect
+ .element(by(ids.tripDestination))
+ .toHaveAttribute('data-destination-status', 'undecided');
+ await expect.element(by(ids.tripRole)).toHaveAttribute('data-role', 'organizer');
+
+ await archiveTrip();
+ await restoreTrip();
+ await expect.element(by(ids.tripArchive)).toBeVisible();
+
+ await openTrips();
+ await expect.element(tripCard(tripName)).toBeVisible();
+});
diff --git a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts b/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
deleted file mode 100644
index ae2ef815..00000000
--- a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import {
- addIdea,
- approveAndApplyIdea,
- by,
- createTrip,
- ids,
- openAppPath,
- openIdeaComparison,
- openTripSection,
- reloadAppPage,
- requestIdeaReview,
- resolveTripDetailConflict,
- signIn,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async ({
- page
-}) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix('detail-conflict');
- const initialName = `Original plan ${suffix}`;
- const sharedName = `Updated shared plan ${suffix}`;
- const ideaName = `details/mixed-resolution-${suffix}`;
- const ideaTripName = `Idea plan ${suffix}`;
- const ideaNotes = `Idea dates ${suffix}`;
-
- const tripId = await createTrip(page, { name: initialName });
- await addIdea(page, { name: ideaName });
- await updateTrip(page, { dateNotes: ideaNotes, name: ideaTripName });
- await openTripSection(page, 'itinerary');
- await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0);
- await expect(page.getByText('The shared trip has changed since you started')).toHaveCount(0);
- await requestIdeaReview(page);
- const firstIdeaUrl = page.url();
-
- await openAppPath(page, `trips/${tripId}/ideas`);
- await addIdea(page, { name: `details/competing-${suffix}` });
- await updateTrip(page, { dateNotes: 'Shared dates', name: sharedName });
- await requestIdeaReview(page);
- await approveAndApplyIdea(page);
-
- await openAppPath(page, firstIdeaUrl);
- await approveAndApplyIdea(page);
- await openIdeaComparison(page);
- await expect(
- page.getByRole('heading', { name: 'Trip details differ from the shared trip' })
- ).toBeVisible();
- await resolveTripDetailConflict(page, {
- choices: [{ field: 'Trip name', source: 'shared' }],
- expectedBranches: [{ field: 'Trip name', idea: ideaTripName, shared: sharedName }],
- expectedPreview: [sharedName, ideaNotes],
- expectedSides: [sharedName, ideaName]
- });
- await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName);
- await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0);
- await reloadAppPage(page);
- await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName);
- await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0);
- await openIdeaComparison(page);
- await expect(page.getByText('The shared trip has changed since you started')).toHaveCount(0);
- await expect(page.getByText(ideaNotes, { exact: true })).toBeVisible();
-});
diff --git a/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx b/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx
new file mode 100644
index 00000000..4163ec05
--- /dev/null
+++ b/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx
@@ -0,0 +1,75 @@
+import {
+ addIdea,
+ approveAndApplyIdea,
+ arrange,
+ by,
+ currentPath,
+ expectGone,
+ ids,
+ openAppPath,
+ openIdea,
+ openIdeaComparison,
+ openTrip,
+ openTripSection,
+ reloadAppPage,
+ requestIdeaReview,
+ resolveTripDetailConflict,
+ uniqueSuffix,
+ updateTrip
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async () => {
+ const suffix = uniqueSuffix('detail-conflict');
+ const initialName = `Original plan ${suffix}`;
+ const sharedName = `Updated shared plan ${suffix}`;
+ const ideaName = `details/mixed-resolution-${suffix}`;
+ const ideaTripName = `Idea plan ${suffix}`;
+ const ideaNotes = `Idea dates ${suffix}`;
+
+ const tripId = await arrange.createTrip({ name: initialName });
+ await openApp();
+ await openTrip(tripId);
+ await addIdea({ name: ideaName });
+ await updateTrip({ dateNotes: ideaNotes, name: ideaTripName });
+ await openTripSection('itinerary');
+ await expect.element(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible();
+ await expectGone(by(ids.itineraryDetailsResolve));
+ await expectGone(page.getByText('The shared trip has changed since you started'));
+ await requestIdeaReview();
+ const firstIdeaUrl = currentPath();
+
+ const competing = await arrange.startIdea({ name: `details/competing-${suffix}`, tripId });
+ await arrange.updateTrip({
+ dateNotes: 'Shared dates',
+ name: sharedName,
+ tripId: competing.workingTripId
+ });
+ await arrange.requestIdeaReview(competing.proposalId);
+ await openIdea({ proposalId: competing.proposalId, tripId });
+ await openIdeaComparison();
+ await approveAndApplyIdea();
+
+ await openAppPath(firstIdeaUrl);
+ await approveAndApplyIdea();
+ await openIdeaComparison();
+ await expect
+ .element(page.getByRole('heading', { name: 'Trip details differ from the shared trip' }))
+ .toBeVisible();
+ await resolveTripDetailConflict({
+ choices: [{ field: 'Trip name', source: 'shared' }],
+ expectedBranches: [{ field: 'Trip name', idea: ideaTripName, shared: sharedName }],
+ expectedPreview: [sharedName, ideaNotes],
+ expectedSides: [sharedName, ideaName]
+ });
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName);
+ await expectGone(by(ids.itineraryDetailsResolve));
+ await reloadAppPage();
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName);
+ await expectGone(by(ids.itineraryDetailsResolve));
+ await openIdeaComparison();
+ await expectGone(page.getByText('The shared trip has changed since you started'));
+ await expect.element(page.getByText(ideaNotes)).toBeVisible();
+});
diff --git a/apps/web/e2e/trips/trip-search.spec.ts b/apps/web/e2e/trips/trip-search.spec.ts
deleted file mode 100644
index 9a35126a..00000000
--- a/apps/web/e2e/trips/trip-search.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- createTrip,
- openTrips,
- searchTrips,
- signIn,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('trip search only shows matching plans and clearing it restores the list', async ({
- page
-}) => {
- test.setTimeout(90_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const first = `Lisbon search ${suffix}`;
- const second = `Porto search ${suffix}`;
- await createTrip(page, { name: first });
- await createTrip(page, { name: second });
- await openTrips(page);
-
- await searchTrips(page, first);
- await expect(tripCard(page, first)).toBeVisible();
- await expect(tripCard(page, second)).toHaveCount(0);
- await searchTrips(page, '');
- await expect(tripCard(page, first)).toBeVisible();
- await expect(tripCard(page, second)).toBeVisible();
-});
diff --git a/apps/web/e2e/trips/trip-search.test.tsx b/apps/web/e2e/trips/trip-search.test.tsx
new file mode 100644
index 00000000..2a409840
--- /dev/null
+++ b/apps/web/e2e/trips/trip-search.test.tsx
@@ -0,0 +1,25 @@
+import {
+ arrange,
+ expectGone,
+ searchTrips,
+ tripCard,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('trip search only shows matching plans and clearing it restores the list', async () => {
+ const suffix = uniqueSuffix();
+ const first = `Lisbon search ${suffix}`;
+ const second = `Porto search ${suffix}`;
+ await arrange.createTrip({ name: first });
+ await arrange.createTrip({ name: second });
+ await openApp();
+
+ await searchTrips(first);
+ await expect.element(tripCard(first)).toBeVisible();
+ await expectGone(tripCard(second));
+ await searchTrips('');
+ await expect.element(tripCard(first)).toBeVisible();
+ await expect.element(tripCard(second)).toBeVisible();
+});
diff --git a/apps/web/e2e/workspace/browse-workspace.spec.ts b/apps/web/e2e/workspace/browse-workspace.spec.ts
deleted file mode 100644
index 15b8584a..00000000
--- a/apps/web/e2e/workspace/browse-workspace.spec.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import {
- addIdea,
- by,
- createIssue,
- createTrip,
- expectHomeDashboard,
- ids,
- openAppPath,
- openChatInbox,
- openFirstMobileListRow,
- openIssuesInbox,
- openMobileChat,
- openSettingsGroupSection,
- openSharedTrip,
- recoverFromNotFound,
- selectSystemTheme,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('group people live in settings, not the sidebar', async ({ page }) => {
- await signIn(page);
- await expect(page.getByRole('link', { name: 'Group', exact: true })).toHaveCount(0);
- await openSettingsGroupSection(page);
- await expect(by(page, ids.inviteMember)).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Invitation codes' })).toBeVisible();
-
- await openAppPath(page, 'group');
- await expect(page).toHaveURL(/\/settings\/group\/?$/u);
- await expect(by(page, ids.inviteMember)).toBeVisible();
-
- await openAppPath(page, 'group/members');
- await expect(page).toHaveURL(/\/settings\/group\/?$/u);
-});
-
-test('workspace routes and 404 recovery work', async ({ page }) => {
- const errors: string[] = [];
- page.on('pageerror', (error) => errors.push(error.message));
- page.on('console', (message) => {
- if (message.type() === 'error') errors.push(message.text());
- });
- await signIn(page);
-
- await expect(by(page, ids.tripsTitle)).toBeVisible();
- await openIssuesInbox(page);
- await openChatInbox(page);
-
- await openAppPath(page, 'does-not-exist');
- await recoverFromNotFound(page);
- await expectHomeDashboard(page);
- expect(errors).toEqual([]);
-});
-
-test('system theme and mobile navigation work', async ({ page }) => {
- await page.emulateMedia({ colorScheme: 'dark' });
- await page.setViewportSize({ width: 390, height: 844 });
- await page.addInitScript(() => localStorage.removeItem('groam-theme'));
- await signIn(page);
- await expect(page.locator('html')).toHaveClass(/dark/u);
-
- const navigation = by(page, ids.mobileNav);
- await expect(navigation).toBeVisible();
- await openMobileChat(page);
- await selectSystemTheme(page);
-});
-
-test('mobile idea and issue lists keep titles readable and rows navigable', async ({ page }) => {
- test.setTimeout(180_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const tripId = await createTrip(page, { name: `Mobile lists ${suffix}` });
- await addIdea(page, { name: `Mobile idea ${suffix}` });
- await openSharedTrip(page);
- await createIssue(page, {
- body: 'Keep this issue title readable on a narrow screen.',
- title: `Mobile issue ${suffix}`,
- tripId
- });
- await page.setViewportSize({ width: 390, height: 844 });
-
- for (const area of ['ideas', 'issues'] as const) {
- await openAppPath(page, area);
- await openFirstMobileListRow(page, area);
- await expect(page).toHaveURL(area === 'ideas' ? /\/ideas\/.+/u : /\/issues\/.+/u);
- await expect(by(page, ids.mobileNav)).toBeVisible();
- }
-});
diff --git a/apps/web/e2e/workspace/browse-workspace.test.tsx b/apps/web/e2e/workspace/browse-workspace.test.tsx
new file mode 100644
index 00000000..2337beab
--- /dev/null
+++ b/apps/web/e2e/workspace/browse-workspace.test.tsx
@@ -0,0 +1,45 @@
+import {
+ by,
+ expectGone,
+ expectHomeDashboard,
+ expectPath,
+ expectWorkspaceReady,
+ ids,
+ openAppPath,
+ openChatInbox,
+ openIssuesInbox,
+ openSettingsGroupSection,
+ recoverFromNotFound
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { openApp } from '@/testing/open-app';
+
+test('group people live in settings, not the sidebar', async () => {
+ await openApp();
+ await expectWorkspaceReady();
+ await expectGone(page.getByRole('link', { name: 'Group' }));
+ await openSettingsGroupSection();
+ await expect.element(by(ids.inviteMember)).toBeVisible();
+ await expect.element(page.getByRole('heading', { name: 'Members' })).toBeVisible();
+ await expect.element(page.getByRole('heading', { name: 'Invitation codes' })).toBeVisible();
+
+ await openAppPath('group');
+ await expectPath(/\/settings\/group\/?$/u);
+ await expect.element(by(ids.inviteMember)).toBeVisible();
+
+ await openAppPath('group/members');
+ await expectPath(/\/settings\/group\/?$/u);
+});
+
+test('workspace routes and 404 recovery work', async () => {
+ await openApp();
+
+ await expect.element(by(ids.tripsTitle)).toBeVisible();
+ await openIssuesInbox();
+ await openChatInbox();
+
+ await openAppPath('does-not-exist');
+ await recoverFromNotFound();
+ await expectHomeDashboard();
+});
diff --git a/apps/web/e2e/workspace/workspace-idea-search.spec.ts b/apps/web/e2e/workspace/workspace-idea-search.spec.ts
deleted file mode 100644
index 99bd8a2a..00000000
--- a/apps/web/e2e/workspace/workspace-idea-search.spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {
- addIdea,
- createTrip,
- ideaRow,
- openWorkspaceIdeas,
- searchIdeas,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('workspace idea search finds the matching proposal and clears cleanly', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const first = `Coast proposal ${suffix}`;
- const second = `Museum proposal ${suffix}`;
- await createTrip(page, { name: `Coast trip ${suffix}` });
- await addIdea(page, { name: first });
- await createTrip(page, { name: `City trip ${suffix}` });
- await addIdea(page, { name: second });
- await openWorkspaceIdeas(page);
-
- await searchIdeas(page, 'Museum proposal');
- await expect(ideaRow(page, second)).toBeVisible();
- await expect(ideaRow(page, first)).toHaveCount(0);
- await searchIdeas(page, '');
- await expect(ideaRow(page, second)).toBeVisible();
- await expect(ideaRow(page, first)).toBeVisible();
-});
diff --git a/apps/web/e2e/workspace/workspace-idea-search.test.tsx b/apps/web/e2e/workspace/workspace-idea-search.test.tsx
new file mode 100644
index 00000000..c87efff2
--- /dev/null
+++ b/apps/web/e2e/workspace/workspace-idea-search.test.tsx
@@ -0,0 +1,29 @@
+import {
+ arrange,
+ expectGone,
+ ideaRow,
+ openWorkspaceIdeas,
+ searchIdeas,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('workspace idea search finds the matching proposal and clears cleanly', async () => {
+ const suffix = uniqueSuffix();
+ const first = `Coast proposal ${suffix}`;
+ const second = `Museum proposal ${suffix}`;
+ const coastTripId = await arrange.createTrip({ name: `Coast trip ${suffix}` });
+ await arrange.startIdea({ name: first, tripId: coastTripId });
+ const cityTripId = await arrange.createTrip({ name: `City trip ${suffix}` });
+ await arrange.startIdea({ name: second, tripId: cityTripId });
+ await openApp();
+ await openWorkspaceIdeas();
+
+ await searchIdeas('Museum proposal');
+ await expect.element(ideaRow({ title: second })).toBeVisible();
+ await expectGone(ideaRow({ title: first }));
+ await searchIdeas('');
+ await expect.element(ideaRow({ title: second })).toBeVisible();
+ await expect.element(ideaRow({ title: first })).toBeVisible();
+});
diff --git a/apps/web/e2e/workspace/workspace-issue-search.spec.ts b/apps/web/e2e/workspace/workspace-issue-search.spec.ts
deleted file mode 100644
index f6eeac71..00000000
--- a/apps/web/e2e/workspace/workspace-issue-search.spec.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {
- createIssue,
- createTrip,
- issueRow,
- openAppPath,
- openIssuesInbox,
- searchIssues,
- signIn,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
-
-test('workspace issue search finds the matching issue and clears cleanly', async ({ page }) => {
- test.setTimeout(120_000);
- await signIn(page);
- const suffix = uniqueSuffix();
- const first = `Museum issue ${suffix}`;
- const second = `Ferry issue ${suffix}`;
- const tripId = await createTrip(page, { name: `Issue search trip ${suffix}` });
- await createIssue(page, { body: 'Check opening hours.', title: first, tripId });
- await openAppPath(page, `trips/${tripId}/issues`);
- await createIssue(page, { body: 'Check departure time.', title: second, tripId });
- await openIssuesInbox(page);
-
- await searchIssues(page, 'Ferry issue');
- await expect(issueRow(page, second)).toBeVisible();
- await expect(issueRow(page, first)).toHaveCount(0);
- await searchIssues(page, '');
- await expect(issueRow(page, first)).toBeVisible();
- await expect(issueRow(page, second)).toBeVisible();
-});
diff --git a/apps/web/e2e/workspace/workspace-issue-search.test.tsx b/apps/web/e2e/workspace/workspace-issue-search.test.tsx
new file mode 100644
index 00000000..04f73591
--- /dev/null
+++ b/apps/web/e2e/workspace/workspace-issue-search.test.tsx
@@ -0,0 +1,28 @@
+import {
+ arrange,
+ expectGone,
+ issueRow,
+ openIssuesInbox,
+ searchIssues,
+ uniqueSuffix
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
+
+test('workspace issue search finds the matching issue and clears cleanly', async () => {
+ const suffix = uniqueSuffix();
+ const first = `Museum issue ${suffix}`;
+ const second = `Ferry issue ${suffix}`;
+ const tripId = await arrange.createTrip({ name: `Issue search trip ${suffix}` });
+ await arrange.createIssue({ body: 'Check opening hours.', title: first, tripId });
+ await arrange.createIssue({ body: 'Check departure time.', title: second, tripId });
+ await openApp();
+ await openIssuesInbox();
+
+ await searchIssues('Ferry issue');
+ await expect.element(issueRow(second)).toBeVisible();
+ await expectGone(issueRow(first));
+ await searchIssues('');
+ await expect.element(issueRow(first)).toBeVisible();
+ await expect.element(issueRow(second)).toBeVisible();
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index 866fac29..d3fec742 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -24,17 +24,14 @@
"@groam/app-actions": "workspace:*",
"@groam/typescript-config": "workspace:*",
"@groam/vitest-config": "workspace:*",
- "@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-cli": "1.167.39",
"@tanstack/router-plugin": "1.168.41",
- "@testing-library/react": "16.3.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-istanbul": "catalog:",
- "jsdom": "30.1.1",
"tailwindcss": "^4.2.2",
"typescript": "~7.0.2",
"vite": "catalog:",
@@ -46,15 +43,11 @@
"scripts": {
"build": "tsc -b && vp build",
"dev": "vp dev --host 0.0.0.0",
- "e2e": "env -u NO_COLOR -u FORCE_COLOR playwright test",
- "e2e:headed": "env -u NO_COLOR -u FORCE_COLOR playwright test --headed",
- "e2e:install": "playwright install chromium",
- "e2e:local": "PLAYWRIGHT_BASE_URL=http://localhost:5173 env -u NO_COLOR -u FORCE_COLOR playwright test",
- "e2e:local:headed": "PLAYWRIGHT_BASE_URL=http://localhost:5173 env -u NO_COLOR -u FORCE_COLOR playwright test --headed",
+ "journeys": "vp test run --project journeys",
"lint": "vp check .",
"preview": "vp preview --host 0.0.0.0",
"routes:generate": "NODE_ENV=production tsr generate",
- "test": "vp test run --coverage",
+ "test": "vp test run --coverage --project unit --project views",
"typecheck": "tsc -b"
},
"type": "module"
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
deleted file mode 100644
index ecb63617..00000000
--- a/apps/web/playwright.config.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { env } from '@groam/env/playwright';
-import { defineConfig, devices } from '@playwright/test';
-
-export default defineConfig({
- expect: { timeout: env.isCI ? 45_000 : 10_000 },
- fullyParallel: false,
- outputDir: 'test-results',
- reporter: env.isCI ? [['github'], ['html', { open: 'never' }]] : 'list',
- retries: env.isCI ? 2 : 0,
- testDir: './e2e',
- timeout: env.isCI ? 180_000 : 30_000,
- use: {
- ...devices['Desktop Chrome'],
- baseURL: env.baseUrl,
- screenshot: 'only-on-failure',
- trace: env.isCI ? 'on-first-retry' : 'retain-on-failure'
- },
- workers: 1
-});
diff --git a/apps/web/src/app-root.tsx b/apps/web/src/app-root.tsx
new file mode 100644
index 00000000..ee0633c5
--- /dev/null
+++ b/apps/web/src/app-root.tsx
@@ -0,0 +1,18 @@
+import { GroamAuthProvider } from '@groam/auth/provider';
+import { UiThemeProvider } from '@groam/ui/components/theme-provider';
+import type { ConvexReactClient } from 'convex/react';
+import { StrictMode } from 'react';
+import App from './app.tsx';
+
+/** The whole app as users get it; journeys mount the same tree. */
+export function AppRoot({ client }: { client: ConvexReactClient }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 49ae7ba4..e24194a5 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -1,7 +1,9 @@
import { Toaster } from '@groam/ui/components/sonner';
import { Spinner } from '@groam/ui/components/spinner';
import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react';
-import { lazy, Suspense } from 'react';
+import { Activity, lazy, Suspense } from 'react';
+import { useSessionChange } from '@/features/auth/hooks/use-session-change';
+import { SessionChangeContext } from '@/features/auth/session-change-context';
const AuthScreen = lazy(() =>
import('@/features/auth/auth-screen').then(({ AuthScreen }) => ({
@@ -26,15 +28,22 @@ function LoadingSession() {
}
export default function App() {
+ const { changeSession, isChangingSession } = useSessionChange();
+
return (
- <>
+
- }>
-
-
+ {/* Hidden, the router keeps its state in case the session survives (a wrong code); once
+ Convex holds a new session it remounts. */}
+ {isChangingSession && }
+
+ }>
+
+
+
}>
@@ -42,6 +51,6 @@ export default function App() {
- >
+
);
}
diff --git a/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx b/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx
index a4cb2765..305ba427 100644
--- a/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx
+++ b/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { AgentRun } from '@/features/agents/hooks/use-agent';
import { testIds } from '@/lib/test-ids';
import { AgentDetailView } from './agent-detail-view';
@@ -45,9 +46,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({
useStartQueuedAssignRuns: () => undefined
}));
-vi.mock('@tanstack/react-router', async () => {
+vi.mock('@tanstack/react-router', async (importOriginal) => {
const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock');
- return createAgentTestRouterMock(vi.fn(), { preventDefault: true });
+ return {
+ ...(await importOriginal()),
+ ...createAgentTestRouterMock(vi.fn(), { preventDefault: true })
+ };
});
beforeEach(() => {
@@ -57,10 +61,8 @@ beforeEach(() => {
state.status = 'Exhausted';
});
-afterEach(cleanup);
-
describe('AgentDetailView', () => {
- test('puts the back link above the title hero and status badges', () => {
+ test('puts the back link above the title hero and status badges', async () => {
state.agent = {
activeRunCount: 1,
assignedIssueCount: 1,
@@ -71,18 +73,20 @@ describe('AgentDetailView', () => {
status: 'working',
surface: 'standalone'
};
- render();
+ await render();
- const back = screen.getByRole('link', { name: 'All agents' });
- const title = screen.getByTestId('agent-detail-title');
- expect(back.getAttribute('href')).toBe('/agents');
- expect(back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(title.textContent).toContain('Issue agent');
- expect(screen.getByText('Works assigned issues')).toBeTruthy();
- expect(screen.getByText('Worker')).toBeTruthy();
+ const back = page.getByRole('link', { name: 'All agents' });
+ const title = page.getByTestId('agent-detail-title');
+ await expect.element(back).toHaveAttribute('href', '/agents');
+ expect(
+ back.element().compareDocumentPosition(title.element()) & Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+ expect(title.element().textContent).toContain('Issue agent');
+ await expect.element(page.getByText('Works assigned issues')).toBeInTheDocument();
+ await expect.element(page.getByText('Worker')).toBeInTheDocument();
});
- test('lists this agent’s runs as links to the run page, not a split detail pane', () => {
+ test('lists this agent’s runs as links to the run page, not a split detail pane', async () => {
state.agent = {
activeRunCount: 0,
assignedIssueCount: 2,
@@ -147,16 +151,18 @@ describe('AgentDetailView', () => {
updatedAt: 1
}
];
- render();
- expect(screen.getByRole('heading', { name: 'Runs' })).toBeTruthy();
- expect(screen.getByRole('link', { name: /Rainy-day option/u }).getAttribute('href')).toBe(
- '/agents/issue/run-rain'
- );
- expect(screen.getByRole('link', { name: /Coast day/u }).getAttribute('href')).toBe(
- '/agents/issue/run-coast'
+ await render();
+ await expect.element(page.getByRole('heading', { name: 'Runs' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: /Rainy-day option/u }))
+ .toHaveAttribute('href', '/agents/issue/run-rain');
+ await expect
+ .element(page.getByRole('link', { name: /Coast day/u }))
+ .toHaveAttribute('href', '/agents/issue/run-coast');
+ await expect.element(page.getByTestId(testIds.agentRunDetail)).not.toBeInTheDocument();
+ await expect.element(page.getByText('Assigned issues')).not.toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentsRoster).element().className).not.toMatch(
+ /max-h-\[50%\]/u
);
- expect(screen.queryByTestId(testIds.agentRunDetail)).toBeNull();
- expect(screen.queryByText('Assigned issues')).toBeNull();
- expect(screen.getByTestId(testIds.agentsRoster).className).not.toMatch(/max-h-\[50%\]/u);
});
});
diff --git a/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx b/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx
index 06d4abfe..7306dbbf 100644
--- a/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx
+++ b/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx
@@ -1,5 +1,7 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { WorkspaceAgentRun } from '@/features/agents/hooks/use-workspace-agent-runs';
import { testIds } from '@/lib/test-ids';
import { AgentsListView } from './agents-list-view';
@@ -36,9 +38,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({
const navigate = vi.hoisted(() => vi.fn());
-vi.mock('@tanstack/react-router', async () => {
+vi.mock('@tanstack/react-router', async (importOriginal) => {
const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock');
- return createAgentTestRouterMock(navigate);
+ return {
+ ...(await importOriginal()),
+ ...createAgentTestRouterMock(navigate)
+ };
});
function run(
@@ -79,24 +84,26 @@ beforeEach(() => {
state.status = 'Exhausted';
});
-afterEach(cleanup);
-
describe('AgentsListView', () => {
- test('shows a labeled loading state while runs load', () => {
+ test('shows a labeled loading state while runs load', async () => {
state.isLoading = true;
- render();
- expect(screen.getByRole('status', { name: 'Loading agent runs…' })).toBeTruthy();
+ await render();
+ await expect
+ .element(page.getByRole('status', { name: 'Loading agent runs…' }))
+ .toBeInTheDocument();
});
- test('shows an empty state when the group has no runs', () => {
- render();
- expect(screen.getByText('Nothing running yet')).toBeTruthy();
- expect(screen.queryByText('Latest run from each agent.')).toBeNull();
- expect(screen.queryByTestId(testIds.agentsRoster)).toBeNull();
- expect(screen.getByRole('heading', { name: 'Background activity' })).toBeTruthy();
+ test('shows an empty state when the group has no runs', async () => {
+ await render();
+ await expect.element(page.getByText('Nothing running yet')).toBeInTheDocument();
+ await expect.element(page.getByText('Latest run from each agent.')).not.toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.agentsRoster)).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Background activity' }))
+ .toBeInTheDocument();
});
- test('lists every run sequentially instead of one latest row per agent', () => {
+ test('lists every run sequentially instead of one latest row per agent', async () => {
state.runs = [
run({
agentId: 'issue',
@@ -135,45 +142,49 @@ describe('AgentsListView', () => {
updatedAt: Date.now() - 10 * 60_000
})
];
- render();
- expect(screen.getByRole('heading', { name: 'Background activity' })).toBeTruthy();
- expect(
- screen.getByText(
- 'Work Groam is doing for your group. Continue planning in chat while it runs.'
+ await render();
+ await expect
+ .element(page.getByRole('heading', { name: 'Background activity' }))
+ .toBeInTheDocument();
+ await expect
+ .element(
+ page.getByText(
+ 'Work Groam is doing for your group. Continue planning in chat while it runs.'
+ )
)
- ).toBeTruthy();
- const roster = screen.getByTestId(testIds.agentsRoster);
- expect(roster).toBeTruthy();
- expect(roster.className).not.toContain('dashboard-lift-card');
- expect(roster.className).not.toContain('rounded-2xl');
- expect(roster.className).toContain('bg-card');
- const tableContainer = roster.querySelector('[data-slot="table-container"]');
- expect(tableContainer?.getAttribute('data-variant')).toBe('list');
- expect(roster.querySelector('table')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Run' })).toBeTruthy();
- expect(screen.getByText('Agent')).toBeTruthy();
- expect(screen.getByText('Called by')).toBeTruthy();
- expect(screen.getByText('Status')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Updated' })).toBeTruthy();
- expect(screen.getAllByTestId(testIds.agentCard)).toHaveLength(4);
- expect(screen.getByRole('link', { name: /Rainy-day option/u }).getAttribute('href')).toBe(
- '/agents/issue/run-issue-new'
- );
- expect(screen.getByRole('link', { name: /Packing help/u }).getAttribute('href')).toBe(
- '/agents/groam/run-chat'
- );
- expect(screen.getAllByText(/Issue agent/u)).toHaveLength(2);
- expect(screen.getByText('Groam', { exact: true })).toBeTruthy();
- expect(screen.getByText(/Idea reviewer/u)).toBeTruthy();
- expect(screen.getByText('Packing help')).toBeTruthy();
- expect(screen.getByText('Rainy-day option')).toBeTruthy();
- expect(screen.getByText('Coast day')).toBeTruthy();
- expect(screen.getByText('Kyoto food crawl')).toBeTruthy();
- expect(screen.getAllByText('Running')).toHaveLength(2);
- expect(screen.getByText('Failed')).toBeTruthy();
- expect(screen.getByText('Done')).toBeTruthy();
- expect(screen.getByText(/The model timed out/u)).toBeTruthy();
- const runCells = screen.getAllByTestId(testIds.agentRunFeedRunCell);
+ .toBeInTheDocument();
+ const roster = page.getByTestId(testIds.agentsRoster);
+ await expect.element(roster).not.toHaveClass('dashboard-lift-card');
+ await expect.element(roster).not.toHaveClass('rounded-2xl');
+ await expect.element(roster).toHaveClass('bg-card');
+ expect(
+ roster.element().querySelector('[data-slot="table-container"]')?.getAttribute('data-variant')
+ ).toBe('list');
+ await expect.element(roster.getByRole('table')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Run' })).toBeInTheDocument();
+ await expect.element(page.getByText('Agent')).toBeInTheDocument();
+ await expect.element(page.getByText('Called by')).toBeInTheDocument();
+ await expect.element(page.getByText('Status')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Updated' })).toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentCard).elements()).toHaveLength(4);
+ await expect
+ .element(page.getByRole('link', { name: /Rainy-day option/u }))
+ .toHaveAttribute('href', '/agents/issue/run-issue-new');
+ await expect
+ .element(page.getByRole('link', { name: /Packing help/u }))
+ .toHaveAttribute('href', '/agents/groam/run-chat');
+ expect(page.getByText(/Issue agent/u).elements()).toHaveLength(2);
+ await expect.element(page.getByText('Groam')).toBeInTheDocument();
+ await expect.element(page.getByText(/Idea reviewer/u)).toBeInTheDocument();
+ await expect.element(page.getByText('Packing help')).toBeInTheDocument();
+ await expect.element(page.getByText('Rainy-day option')).toBeInTheDocument();
+ await expect.element(page.getByText('Coast day')).toBeInTheDocument();
+ await expect.element(page.getByText('Kyoto food crawl')).toBeInTheDocument();
+ expect(page.getByText('Running').elements()).toHaveLength(2);
+ await expect.element(page.getByText('Failed')).toBeInTheDocument();
+ await expect.element(page.getByText('Done')).toBeInTheDocument();
+ await expect.element(page.getByText(/The model timed out/u)).toBeInTheDocument();
+ const runCells = page.getByTestId(testIds.agentRunFeedRunCell).elements();
expect(runCells).toHaveLength(4);
for (const cell of runCells) {
expect(cell.className).toContain('min-w-0');
@@ -181,19 +192,19 @@ describe('AgentsListView', () => {
expect(title?.className).toContain('truncate');
expect(title?.className).toContain('font-semibold');
}
- const rosterRow = screen.getAllByTestId(testIds.agentCard)[0];
+ const rosterRow = page.getByTestId(testIds.agentCard).elements()[0];
expect(rosterRow?.tagName).toBe('TR');
expect(rosterRow?.className).toContain('cursor-pointer');
- expect(screen.queryByText('Nothing running yet')).toBeNull();
- expect(screen.queryByText('Idle')).toBeNull();
- expect(screen.queryByText('Latest run from each agent.')).toBeNull();
- expect(screen.queryByText('Latest run')).toBeNull();
- const retry = screen.getByRole('button', { name: 'Retry from zero' });
+ await expect.element(page.getByText('Nothing running yet')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Idle')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Latest run from each agent.')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Latest run')).not.toBeInTheDocument();
+ const retry = page.getByRole('button', { name: 'Retry from zero' }).element();
expect(retry.textContent).toBe('');
- expect(screen.queryByText('Retry from zero')).toBeNull();
+ await expect.element(page.getByText('Retry from zero')).not.toBeInTheDocument();
});
- test('loads the next page of runs', () => {
+ test('loads the next page of runs', async () => {
state.status = 'CanLoadMore';
state.runs = [
run({
@@ -201,12 +212,12 @@ describe('AgentsListView', () => {
title: 'First page run'
})
];
- render();
- fireEvent.click(screen.getByTestId(testIds.agentsLoadMore));
+ await render();
+ await page.getByTestId(testIds.agentsLoadMore).click();
expect(state.loadMore).toHaveBeenCalledWith(25);
});
- test('filters the loaded runs without hiding the table chrome', () => {
+ test('filters the loaded runs without hiding the table chrome', async () => {
state.runs = [
run({
id: 'run-rain' as WorkspaceAgentRun['id'],
@@ -219,11 +230,56 @@ describe('AgentsListView', () => {
title: 'Packing help'
})
];
- render();
- fireEvent.change(screen.getByTestId(testIds.agentsRunsFilter), {
- target: { value: 'packing' }
+ await render();
+ await page.getByTestId(testIds.agentsRunsFilter).fill('packing');
+ await expect.element(page.getByText('Packing help')).toBeInTheDocument();
+ await expect.element(page.getByText('Rainy-day option')).not.toBeInTheDocument();
+ });
+
+ describe.each([
+ ['desktop', 1280],
+ ['phone', 390]
+ ])('layout on a %s', (_device, width) => {
+ const fits = (element: Element) => {
+ const box = element.getBoundingClientRect();
+ return box.left >= 0 && box.right <= document.documentElement.clientWidth;
+ };
+
+ test('puts the title above its description and the empty state below, inside the page', async () => {
+ await useViewport(width, 844);
+ await render();
+ const title = page.getByTestId(testIds.agentsTitle);
+ const description = page.getByText(/^Work Groam is doing for your group/u);
+ const empty = page.getByText('Nothing running yet');
+ await expect.element(empty).toBeVisible();
+ const titleBox = title.element().getBoundingClientRect();
+ const descriptionBox = description.element().getBoundingClientRect();
+ const emptyBox = empty.element().getBoundingClientRect();
+ expect(titleBox.bottom).toBeLessThanOrEqual(descriptionBox.top);
+ expect(descriptionBox.bottom).toBeLessThanOrEqual(emptyBox.top);
+ for (const element of [title, description, empty]) expect(fits(element.element())).toBe(true);
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
+ });
+
+ test('keeps a populated run table inside the page', async () => {
+ await useViewport(width, 844);
+ state.runs = [
+ run({
+ id: 'run-long' as WorkspaceAgentRun['id'],
+ title: 'A very long run title that has to truncate instead of pushing the page sideways'
+ })
+ ];
+ await render();
+ // On a phone the wide table scrolls inside its own frame instead of the page.
+ await expect
+ .element(page.getByRole('link', { name: /A very long run title/u }))
+ .toBeInTheDocument();
+ expect(fits(page.getByTestId(testIds.agentsRoster).element())).toBe(true);
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
});
- expect(screen.getByText('Packing help')).toBeTruthy();
- expect(screen.queryByText('Rainy-day option')).toBeNull();
});
});
diff --git a/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx
index 2026fee2..15995e11 100644
--- a/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx
+++ b/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { AgentRunEvent } from '@/features/agents/hooks/use-agent-run';
import { testIds } from '@/lib/test-ids';
import { AgentRunLog } from './agent-run-log';
@@ -43,52 +44,54 @@ const events: AgentRunEvent[] = [
}
];
-afterEach(cleanup);
-
describe('AgentRunLog', () => {
- test('filters tool calls and failures without changing event order', () => {
- render();
- expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(3);
- fireEvent.click(screen.getByRole('button', { name: 'Tool calls' }));
- expect(screen.queryByText('Started review')).toBeNull();
- expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(2);
- fireEvent.click(screen.getByRole('button', { name: 'Errors' }));
- expect(screen.getByRole('button', { name: 'Errors' }).getAttribute('aria-pressed')).toBe(
- 'true'
+ test('filters tool calls and failures without changing event order', async () => {
+ await render();
+ expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(3);
+ await page.getByRole('button', { name: 'Tool calls' }).click();
+ await expect.element(page.getByText('Started review')).not.toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(2);
+ await page.getByRole('button', { name: 'Errors' }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Errors' }))
+ .toHaveAttribute('aria-pressed', 'true');
+ expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(1);
+ await expect.element(page.getByText('Connection unavailable')).toBeInTheDocument();
+ await page.getByRole('button', { name: 'All activity' }).click();
+ expect(page.getByTestId(testIds.agentEvent).elements()[0].textContent).toContain(
+ 'Started review'
);
- expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(1);
- expect(screen.getByText('Connection unavailable')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'All activity' }));
- expect(screen.getAllByTestId(testIds.agentEvent)[0].textContent).toContain('Started review');
});
- test('distinguishes loading activity from an empty completed run', () => {
- const { rerender } = render();
- expect(screen.getByRole('status', { name: 'Loading activity' })).toBeTruthy();
- expect(screen.queryByText('No activity recorded')).toBeNull();
- rerender();
- expect(screen.getByText('No activity recorded')).toBeTruthy();
- expect(screen.queryByRole('status')).toBeNull();
+ test('distinguishes loading activity from an empty completed run', async () => {
+ const { rerender } = await render();
+ await expect
+ .element(page.getByRole('status', { name: 'Loading activity' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('No activity recorded')).not.toBeInTheDocument();
+ await rerender();
+ await expect.element(page.getByText('No activity recorded')).toBeInTheDocument();
+ await expect.element(page.getByRole('status')).not.toBeInTheDocument();
});
- test('explains the live waiting state', () => {
- render();
- expect(screen.getByText('Waiting for activity')).toBeTruthy();
- expect(screen.getByText('Updates automatically')).toBeTruthy();
+ test('explains the live waiting state', async () => {
+ await render();
+ await expect.element(page.getByText('Waiting for activity')).toBeInTheDocument();
+ await expect.element(page.getByText('Updates automatically')).toBeInTheDocument();
});
- test('keeps pagination available when a filter has no matches', () => {
- render(
+ test('keeps pagination available when a filter has no matches', async () => {
+ await render(
Load earlier} />
);
- fireEvent.click(screen.getByRole('button', { name: 'Errors' }));
- expect(screen.getByText('No matching activity')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Load earlier' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Errors' }).click();
+ await expect.element(page.getByText('No matching activity')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeInTheDocument();
});
- test('labels tool inputs and outputs inside a collapsed disclosure', () => {
- render();
- const disclosure = screen.getByText('Tool payload').closest('details');
+ test('labels tool inputs and outputs inside a collapsed disclosure', async () => {
+ await render();
+ const disclosure = page.getByText('Tool payload').element().closest('details');
expect(disclosure?.open).toBe(false);
expect(disclosure?.textContent).toContain('Input');
expect(disclosure?.textContent).toContain('Output');
diff --git a/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx
index 40ec4d2c..7867ad74 100644
--- a/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx
+++ b/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx
@@ -1,15 +1,14 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { testIds } from '@/lib/test-ids';
import { AgentRunReport } from './agent-run-report';
-afterEach(cleanup);
-
const activityId = 'jh7abcdefghijklmnopqrstuvwxyz014';
describe('AgentRunReport', () => {
- test('renders structured cards instead of a raw pre-wrap dump', () => {
- render(
+ test('renders structured cards instead of a raw pre-wrap dump', async () => {
+ await render(
);
- expect(screen.getByTestId(testIds.agentRunReport)).toBeTruthy();
- expect(screen.getByText('On the draft')).toBeTruthy();
- expect(screen.getByText('Not applied')).toBeTruthy();
- expect(screen.getByText(/rainy-day museum/u)).toBeTruthy();
- expect(screen.getByText('€24')).toBeTruthy();
- expect(screen.getByText('Could not add the funicular')).toBeTruthy();
- expect(screen.getAllByTestId(testIds.agentRunReportSection)).toHaveLength(2);
+ await expect.element(page.getByTestId(testIds.agentRunReport)).toBeInTheDocument();
+ await expect.element(page.getByText('On the draft')).toBeInTheDocument();
+ await expect.element(page.getByText('Not applied')).toBeInTheDocument();
+ await expect.element(page.getByText(/rainy-day museum/u)).toBeInTheDocument();
+ await expect.element(page.getByText('€24')).toBeInTheDocument();
+ await expect.element(page.getByText('Could not add the funicular')).toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentRunReportSection).elements()).toHaveLength(2);
expect(document.querySelector('p.whitespace-pre-wrap')).toBeNull();
});
});
diff --git a/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx
index d4b4d57e..6f94487b 100644
--- a/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx
+++ b/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page, userEvent } from 'vite-plus/test/browser';
import type { WorkspaceAgentRun } from '@/features/agents/hooks/use-workspace-agent-runs';
import { testIds } from '@/lib/test-ids';
import { AgentRunTable } from './agent-run-table';
@@ -11,13 +12,14 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({
useAgentRunControls: () => ({ retry, start: vi.fn(), stop: vi.fn() })
}));
-vi.mock('@tanstack/react-router', async () => {
+vi.mock('@tanstack/react-router', async (importOriginal) => {
const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock');
- return createAgentTestRouterMock(navigate);
+ return {
+ ...(await importOriginal()),
+ ...createAgentTestRouterMock(navigate)
+ };
});
-afterEach(cleanup);
-
const longError =
'Groam AI is temporarily unavailable. Check your provider settings and try again in a few minutes.';
@@ -51,32 +53,32 @@ function run(overrides: Partial = {}): WorkspaceAgentRun {
};
}
-test('renders run, agent, caller, status, and updated columns', () => {
- render();
- const row = screen.getByTestId(testIds.agentCard);
+test('renders run, agent, caller, status, and updated columns', async () => {
+ await render();
+ const row = page.getByTestId(testIds.agentCard).element();
expect(row.tagName).toBe('TR');
- expect(screen.getByText('Idea reviewer')).toBeTruthy();
- expect(screen.getByText('Alex')).toBeTruthy();
- expect(screen.getByText('Failed')).toBeTruthy();
+ await expect.element(page.getByText('Idea reviewer')).toBeInTheDocument();
+ await expect.element(page.getByText('Alex')).toBeInTheDocument();
+ await expect.element(page.getByText('Failed')).toBeInTheDocument();
- const cell = screen.getByTestId(testIds.agentRunFeedRunCell);
+ const cell = page.getByTestId(testIds.agentRunFeedRunCell).element();
expect(cell.className).toContain('min-w-0');
- const title = screen.getByRole('link', { name: /Kyoto food crawl/u });
+ const title = page.getByRole('link', { name: /Kyoto food crawl/u }).element();
expect(title.className).toContain('truncate');
expect(title.className).toContain('font-semibold');
expect(title.getAttribute('href')).toBe('/agents/reviewer/run-failed');
- const detail = screen.getByText(/Groam AI is temporarily unavailable/u);
+ const detail = page.getByText(/Groam AI is temporarily unavailable/u).element();
expect(detail.className).toContain('truncate');
expect(detail.className).toContain('text-xs');
- expect(screen.queryByTestId(testIds.agentRetryFromZero)).toBeNull();
+ await expect.element(page.getByTestId(testIds.agentRetryFromZero)).not.toBeInTheDocument();
});
-test('retries from zero without navigating away', () => {
- render();
- const retryButton = screen.getByTestId(testIds.agentRetryFromZero);
+test('retries from zero without navigating away', async () => {
+ await render();
+ const retryButton = page.getByTestId(testIds.agentRetryFromZero).element();
expect(retryButton.textContent).toBe('');
- fireEvent.click(retryButton);
+ await userEvent.click(retryButton);
expect(retry).toHaveBeenCalledWith({ runId: 'run-failed' });
expect(navigate).not.toHaveBeenCalled();
});
diff --git a/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx
index 74c64a4d..37e3fcbb 100644
--- a/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx
+++ b/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx
@@ -1,7 +1,8 @@
import { agentRunEventLabels } from '@groam/ai/backend/runs/events';
import { agentRunDuration, agentRunTime } from '@groam/ai/backend/runs/time';
-import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { AgentRun } from '@/features/agents/hooks/use-agent';
import { testIds } from '@/lib/test-ids';
import { AgentRunView } from './agent-run-view';
@@ -67,9 +68,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({
useStartQueuedAssignRuns: () => undefined
}));
-vi.mock('@tanstack/react-router', async () => {
+vi.mock('@tanstack/react-router', async (importOriginal) => {
const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock');
- return createAgentTestRouterMock(navigate, { preventDefault: true });
+ return {
+ ...(await importOriginal()),
+ ...createAgentTestRouterMock(navigate, { preventDefault: true })
+ };
});
function issueAgent() {
@@ -122,10 +126,8 @@ beforeEach(() => {
state.runs = [];
});
-afterEach(cleanup);
-
describe('AgentRunView', () => {
- test('shows the run log for chat Groam instead of sending people to the thread', () => {
+ test('shows the run log for chat Groam instead of sending people to the thread', async () => {
state.agent = {
activeRunCount: 1,
assignedIssueCount: 0,
@@ -164,41 +166,44 @@ describe('AgentRunView', () => {
tripId: null
});
state.runs = [state.run];
- render();
- expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Packing help');
- expect(screen.getByText('Open chat')).toBeTruthy();
- expect(screen.getByTestId(testIds.agentRunContext).textContent).toContain('Lea');
- expect(screen.getByRole('link', { name: 'Packing help' }).getAttribute('href')).toBe(
- '/chat/discussion-1'
- );
- expect(screen.getByText('Timeline')).toBeTruthy();
- expect(screen.queryByText('Worker')).toBeNull();
- expect(screen.queryByText(/transcript in the discussion/u)).toBeNull();
- expect(screen.getByText('Report')).toBeTruthy();
- expect(screen.getByText('Bring one universal adapter.')).toBeTruthy();
- expect(screen.getByTestId('agent-event').textContent).toContain('Responding');
- expect(screen.getAllByText('Running').length).toBe(1);
- expect(screen.queryByText('Assigned issues')).toBeNull();
+ await render();
+ expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain('Packing help');
+ await expect.element(page.getByText('Open chat')).toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentRunContext).element().textContent).toContain('Lea');
+ await expect
+ .element(page.getByRole('link', { name: 'Packing help' }))
+ .toHaveAttribute('href', '/chat/discussion-1');
+ await expect.element(page.getByText('Timeline')).toBeInTheDocument();
+ await expect.element(page.getByText('Worker')).not.toBeInTheDocument();
+ await expect.element(page.getByText(/transcript in the discussion/u)).not.toBeInTheDocument();
+ await expect.element(page.getByText('Report')).toBeInTheDocument();
+ await expect.element(page.getByText('Bring one universal adapter.')).toBeInTheDocument();
+ expect(page.getByTestId('agent-event').element().textContent).toContain('Responding');
+ expect(page.getByText('Running').elements().length).toBe(1);
+ await expect.element(page.getByText('Assigned issues')).not.toBeInTheDocument();
});
- test('keeps All agents above the run title and links the agent as a crumb', () => {
+ test('keeps All agents above the run title and links the agent as a crumb', async () => {
state.agent = issueAgent();
state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' });
state.runs = [state.run];
- render();
+ await render();
- const back = screen.getByRole('link', { name: 'All agents' });
- const crumb = screen.getByRole('link', { name: 'Issue agent' });
- const title = screen.getByTestId(testIds.agentRunTitle);
- expect(back.getAttribute('href')).toBe('/agents');
- expect(crumb.getAttribute('href')).toBe('/agents/issue');
- expect(back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(title.textContent).toContain('Rainy-day option');
- expect(screen.queryByText('Worker')).toBeNull();
- expect(screen.queryByText('Needs attention')).toBeNull();
+ const back = page.getByRole('link', { name: 'All agents' });
+ const title = page.getByTestId(testIds.agentRunTitle);
+ await expect.element(back).toHaveAttribute('href', '/agents');
+ await expect
+ .element(page.getByRole('link', { name: 'Issue agent' }))
+ .toHaveAttribute('href', '/agents/issue');
+ expect(
+ back.element().compareDocumentPosition(title.element()) & Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+ expect(title.element().textContent).toContain('Rainy-day option');
+ await expect.element(page.getByText('Worker')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Needs attention')).not.toBeInTheDocument();
});
- test('shows this run’s log without issue or trip metadata clutter', () => {
+ test('shows this run’s log without issue or trip metadata clutter', async () => {
state.agent = issueAgent();
state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' });
state.runs = [state.run];
@@ -215,28 +220,28 @@ describe('AgentRunView', () => {
toolName: null
}
];
- render();
- expect(screen.queryByText('Worker')).toBeNull();
- expect(screen.getAllByText('Rainy-day option').length).toBeGreaterThan(0);
- expect(screen.getByText('Report')).toBeTruthy();
- expect(screen.getByText('Opened a draft idea.')).toBeTruthy();
- const started = screen.getByTestId(testIds.agentEvent);
- expect(started.textContent).toContain('Started working on');
- expect(started.textContent).toContain('Rainy-day option');
- expect(screen.queryByText('Started working on this issue')).toBeNull();
- expect(
- within(started).getByRole('link', { name: 'Rainy-day option' }).getAttribute('href')
- ).toBe('/issues/issue-1');
- expect(screen.getByText('Called by')).toBeTruthy();
- const context = screen.getByTestId(testIds.agentRunContext);
+ await render();
+ await expect.element(page.getByText('Worker')).not.toBeInTheDocument();
+ expect(page.getByText('Rainy-day option').elements().length).toBeGreaterThan(0);
+ await expect.element(page.getByText('Report')).toBeInTheDocument();
+ await expect.element(page.getByText('Opened a draft idea.')).toBeInTheDocument();
+ const started = page.getByTestId(testIds.agentEvent);
+ expect(started.element().textContent).toContain('Started working on');
+ expect(started.element().textContent).toContain('Rainy-day option');
+ await expect.element(page.getByText('Started working on this issue')).not.toBeInTheDocument();
+ await expect
+ .element(started.getByRole('link', { name: 'Rainy-day option' }))
+ .toHaveAttribute('href', '/issues/issue-1');
+ await expect.element(page.getByText('Called by')).toBeInTheDocument();
+ const context = page.getByTestId(testIds.agentRunContext).element();
expect(context.textContent).not.toContain('Issue');
expect(context.textContent).not.toContain('Trip');
expect(context.textContent).not.toMatch(/\bIdea\b/u);
- expect(screen.queryByRole('link', { name: 'Lisbon' })).toBeNull();
- expect(screen.getByText('Timeline')).toBeTruthy();
+ await expect.element(page.getByRole('link', { name: 'Lisbon' })).not.toBeInTheDocument();
+ await expect.element(page.getByText('Timeline')).toBeInTheDocument();
});
- test('links the issue title on the started event from related data when detail is missing', () => {
+ test('links the issue title on the started event from related data when detail is missing', async () => {
state.agent = issueAgent();
state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' });
state.runs = [state.run];
@@ -253,15 +258,15 @@ describe('AgentRunView', () => {
toolName: null
}
];
- render();
- const started = screen.getByTestId(testIds.agentEvent);
- expect(started.textContent).toContain('Started working on');
- expect(
- within(started).getByRole('link', { name: 'Rainy-day option' }).getAttribute('href')
- ).toBe('/issues/issue-1');
+ await render();
+ const started = page.getByTestId(testIds.agentEvent);
+ expect(started.element().textContent).toContain('Started working on');
+ await expect
+ .element(started.getByRole('link', { name: 'Rainy-day option' }))
+ .toHaveAttribute('href', '/issues/issue-1');
});
- test('shows only this run’s title, not other activities', () => {
+ test('shows only this run’s title, not other activities', async () => {
state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' };
const rain = run({
completedAt: 2,
@@ -287,14 +292,16 @@ describe('AgentRunView', () => {
});
state.run = rain;
state.runs = [rain, coast];
- render();
- expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Rainy-day option');
- expect(screen.queryByTestId(testIds.agentOtherRuns)).toBeNull();
- expect(screen.queryByRole('link', { name: /Coast day/u })).toBeNull();
- expect(screen.queryByText('Coast day')).toBeNull();
+ await render();
+ expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain(
+ 'Rainy-day option'
+ );
+ await expect.element(page.getByTestId(testIds.agentOtherRuns)).not.toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: /Coast day/u })).not.toBeInTheDocument();
+ await expect.element(page.getByText('Coast day')).not.toBeInTheDocument();
});
- test('links the related idea without inventing a generated-idea event', () => {
+ test('links the related idea without inventing a generated-idea event', async () => {
state.agent = {
activeRunCount: 0,
assignedIssueCount: 0,
@@ -352,34 +359,36 @@ describe('AgentRunView', () => {
toolName: null
}
];
- render();
- expect(screen.getByText('Idea reviewer')).toBeTruthy();
- expect(screen.queryByTestId(testIds.agentAssignedIssues)).toBeNull();
- expect(screen.getByText('Called by')).toBeTruthy();
- const idea = screen.getByTestId(testIds.agentRunIdea);
+ await render();
+ await expect.element(page.getByText('Idea reviewer')).toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.agentAssignedIssues)).not.toBeInTheDocument();
+ await expect.element(page.getByText('Called by')).toBeInTheDocument();
+ const idea = page.getByTestId(testIds.agentRunIdea).element();
expect(idea.textContent).toContain('Kyoto food crawl');
- expect(screen.getByRole('link', { name: 'Open idea' }).getAttribute('href')).toContain(
+ expect(page.getByRole('link', { name: 'Open idea' }).element().getAttribute('href')).toContain(
'/trips/trip-1/ideas/proposal-1/'
);
- const events = screen.getAllByTestId(testIds.agentEvent);
+ const events = page.getByTestId(testIds.agentEvent).elements();
expect(events).toHaveLength(3);
expect(events.some((event) => event.textContent?.includes('Thought'))).toBe(true);
expect(events.some((event) => event.textContent?.includes('Check whether day 3'))).toBe(true);
expect(events.some((event) => event.textContent?.includes('Read itinerary'))).toBe(true);
expect(events.some((event) => event.textContent?.includes('Started review'))).toBe(true);
- expect(screen.getByRole('button', { name: 'Thoughts' })).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Thoughts' }));
- const thoughts = screen.getAllByTestId(testIds.agentEvent);
+ await expect.element(page.getByRole('button', { name: 'Thoughts' })).toBeInTheDocument();
+ await page.getByRole('button', { name: 'Thoughts' }).click();
+ const thoughts = page.getByTestId(testIds.agentEvent).elements();
expect(thoughts).toHaveLength(1);
expect(thoughts[0]?.textContent).toContain('Check whether day 3');
expect(idea.textContent).not.toContain('generated');
- expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Kyoto food crawl');
- const context = screen.getByTestId(testIds.agentRunContext);
+ expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain(
+ 'Kyoto food crawl'
+ );
+ const context = page.getByTestId(testIds.agentRunContext).element();
expect(context.textContent).not.toMatch(/\bIdea\b/u);
expect(context.textContent).not.toContain('Trip');
});
- test('omits the idea callout when the run has no proposal', () => {
+ test('omits the idea callout when the run has no proposal', async () => {
state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' };
state.run = run({
completedAt: 1,
@@ -393,12 +402,14 @@ describe('AgentRunView', () => {
updatedAt: 1
});
state.runs = [state.run];
- render();
- expect(screen.queryByTestId(testIds.agentRunIdea)).toBeNull();
- expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Other activity');
+ await render();
+ await expect.element(page.getByTestId(testIds.agentRunIdea)).not.toBeInTheDocument();
+ expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain(
+ 'Other activity'
+ );
});
- test('shows run duration, timestamps, tool payload, and load earlier', () => {
+ test('shows run duration, timestamps, tool payload, and load earlier', async () => {
const startedAt = 1_000;
const completedAt = 9_000;
state.agent = {
@@ -431,21 +442,25 @@ describe('AgentRunView', () => {
toolName: 'getItinerary'
}
];
- render();
- expect(screen.getAllByText('Done').length).toBe(1);
- expect(screen.getByText('Called by')).toBeTruthy();
- expect(screen.getByText('Lea')).toBeTruthy();
- expect(screen.getByText('Timeline')).toBeTruthy();
- expect(
- screen.getByText(new RegExp(`${agentRunDuration(startedAt, completedAt)}`, 'u'))
- ).toBeTruthy();
- expect(screen.getByTestId('agent-event').textContent).toContain(agentRunTime(startedAt));
- expect(screen.getByTestId('agent-event').textContent).toContain('getItinerary');
- expect(screen.getByTestId('agent-event').textContent).toContain('"tripId": "trip-1"');
- expect(screen.getByTestId('agent-event').textContent).toContain('"tripName": "Lisbon"');
- expect(screen.getByRole('button', { name: 'Load earlier' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Retry from zero' })).toBeNull();
- expect(screen.getByRole('button', { name: 'Re-run' })).toBeTruthy();
+ await render();
+ expect(page.getByText('Done').elements().length).toBe(1);
+ await expect.element(page.getByText('Called by')).toBeInTheDocument();
+ await expect.element(page.getByText('Lea')).toBeInTheDocument();
+ await expect.element(page.getByText('Timeline')).toBeInTheDocument();
+ await expect
+ .element(page.getByText(new RegExp(`${agentRunDuration(startedAt, completedAt)}`, 'u')))
+ .toBeInTheDocument();
+ expect(page.getByTestId('agent-event').element().textContent).toContain(
+ agentRunTime(startedAt)
+ );
+ expect(page.getByTestId('agent-event').element().textContent).toContain('getItinerary');
+ expect(page.getByTestId('agent-event').element().textContent).toContain('"tripId": "trip-1"');
+ expect(page.getByTestId('agent-event').element().textContent).toContain('"tripName": "Lisbon"');
+ await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Retry from zero' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Re-run' })).toBeInTheDocument();
});
test('re-runs a finished issue agent and opens the new run', async () => {
@@ -460,8 +475,8 @@ describe('AgentRunView', () => {
updatedAt: 2
});
state.runs = [state.run];
- render();
- fireEvent.click(screen.getByTestId(testIds.agentRerun));
+ await render();
+ await page.getByTestId(testIds.agentRerun).click();
expect(retry).toHaveBeenCalledWith({ runId: 'run-done' });
await vi.waitFor(() => {
expect(navigate).toHaveBeenCalledWith({
@@ -471,7 +486,7 @@ describe('AgentRunView', () => {
});
});
- test('hides Finished while queued and disables load earlier while fetching', () => {
+ test('hides Finished while queued and disables load earlier while fetching', async () => {
state.agent = issueAgent();
state.eventsStatus = 'LoadingMore';
state.run = run({
@@ -482,12 +497,10 @@ describe('AgentRunView', () => {
title: 'Rainy-day option'
});
state.runs = [state.run];
- render();
- expect(screen.getByText('Waiting to start')).toBeTruthy();
- expect(screen.queryByText('Finished')).toBeNull();
- expect(screen.getByRole('button', { name: 'Load earlier' }).hasAttribute('disabled')).toBe(
- true
- );
+ await render();
+ await expect.element(page.getByText('Waiting to start')).toBeInTheDocument();
+ await expect.element(page.getByText('Finished')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeDisabled();
});
test('retries from zero and opens the new run', async () => {
@@ -504,8 +517,8 @@ describe('AgentRunView', () => {
updatedAt: 2
});
state.runs = [state.run];
- render();
- fireEvent.click(screen.getByTestId(testIds.agentRetryFromZero));
+ await render();
+ await page.getByTestId(testIds.agentRetryFromZero).click();
expect(retry).toHaveBeenCalledWith({ runId: 'run-failed' });
await vi.waitFor(() => {
expect(navigate).toHaveBeenCalledWith({
@@ -515,7 +528,7 @@ describe('AgentRunView', () => {
});
});
- test('does not lock a competing activity pane on the run destination', () => {
+ test('does not lock a competing activity pane on the run destination', async () => {
state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' };
state.run = run({
completedAt: 1,
@@ -538,16 +551,18 @@ describe('AgentRunView', () => {
updatedAt: index + 1
})
);
- render();
- expect(screen.getByTestId(testIds.agentDetailWorkspace).className).not.toMatch(
- /overflow-hidden/u
- );
- expect(screen.getByTestId(testIds.agentRunDetail).className).not.toMatch(/overflow-hidden/u);
- expect(screen.queryByTestId(testIds.agentOtherRuns)).toBeNull();
- expect(screen.queryByText('Activity 1')).toBeNull();
+ await render();
+ await expect
+ .element(page.getByTestId(testIds.agentDetailWorkspace))
+ .not.toHaveClass('overflow-hidden');
+ await expect
+ .element(page.getByTestId(testIds.agentRunDetail))
+ .not.toHaveClass('overflow-hidden');
+ await expect.element(page.getByTestId(testIds.agentOtherRuns)).not.toBeInTheDocument();
+ await expect.element(page.getByText('Activity 1')).not.toBeInTheDocument();
});
- test('shows the run title and full error without truncating in the destination', () => {
+ test('shows the run title and full error without truncating in the destination', async () => {
const firstError = 'The model timed out while writing the rainy-day option.';
state.agent = { ...issueAgent(), activeRunCount: 0, lastActivityAt: 2, status: 'failed' };
state.run = run({
@@ -575,18 +590,22 @@ describe('AgentRunView', () => {
}
];
state.runs = [state.run];
- render();
- const detail = screen.getByTestId(testIds.agentRunDetail);
- expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Rainy-day option');
- expect(within(detail).getByText(firstError).className).toMatch(/whitespace-pre-wrap/u);
- expect(within(detail).getAllByText(firstError)).toHaveLength(1);
- expect(within(screen.getByTestId(testIds.agentEvent)).getByText('Run failed')).toBeTruthy();
- expect(screen.queryByText('Needs attention')).toBeNull();
- expect(screen.queryByText('Worker')).toBeNull();
+ await render();
+ const detail = page.getByTestId(testIds.agentRunDetail);
+ expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain(
+ 'Rainy-day option'
+ );
+ await expect.element(detail.getByText(firstError)).toHaveClass('whitespace-pre-wrap');
+ expect(detail.getByText(firstError).elements()).toHaveLength(1);
+ await expect
+ .element(page.getByTestId(testIds.agentEvent).getByText('Run failed'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Needs attention')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Worker')).not.toBeInTheDocument();
});
});
-test('explains provider authentication failures without offering an unsupported reviewer retry', () => {
+test('explains provider authentication failures without offering an unsupported reviewer retry', async () => {
state.agent = { ...issueAgent(), id: 'reviewer', label: 'Idea reviewer' };
state.run = run({
agentId: 'reviewer',
@@ -597,13 +616,15 @@ test('explains provider authentication failures without offering an unsupported
report: null,
title: 'Review the draft'
});
- render();
- expect(screen.getByText(/organization or deployment admin/u)).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Retry from zero' })).toBeNull();
- expect(screen.getByText('No activity recorded')).toBeTruthy();
+ await render();
+ await expect.element(page.getByText(/organization or deployment admin/u)).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Retry from zero' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByText('No activity recorded')).toBeInTheDocument();
});
-test('does not present a last update as an unrecorded start or finish', () => {
+test('does not present a last update as an unrecorded start or finish', async () => {
state.agent = issueAgent();
state.run = run({
id: 'missing-times' as AgentRun['id'],
@@ -612,19 +633,18 @@ test('does not present a last update as an unrecorded start or finish', () => {
status: 'failed',
title: 'Review the draft'
});
- render();
- const context = screen.getByTestId(testIds.agentRunContext);
- expect(within(context).getAllByText('Not recorded')).toHaveLength(2);
- expect(within(context).queryByText('Duration')).toBeNull();
+ await render();
+ const context = page.getByTestId(testIds.agentRunContext);
+ await expect.poll(() => context.getByText('Not recorded').elements()).toHaveLength(2);
+ await expect.element(context.getByText('Duration')).not.toBeInTheDocument();
});
test('copies the run identifier for support and investigation', async () => {
- const writeText = vi.fn().mockResolvedValue(undefined);
- Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
state.agent = issueAgent();
state.run = run({ id: 'copy-this-run' as AgentRun['id'], title: 'Review the draft' });
- render();
- fireEvent.click(screen.getByRole('button', { name: 'Copy run ID' }));
+ await render();
+ await page.getByRole('button', { name: 'Copy run ID' }).click();
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('copy-this-run'));
});
@@ -636,15 +656,14 @@ test('prevents duplicate stop requests and shows pending feedback', async () =>
stop.mockReturnValue(pending);
state.agent = issueAgent();
state.run = run({ id: 'stop-this-run' as AgentRun['id'], title: 'Review the draft' });
- render();
- fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
- const stopping = screen.getByRole('button', { name: 'Stopping…' });
- expect(stopping.hasAttribute('disabled')).toBe(true);
- fireEvent.click(stopping);
+ await render();
+ await page.getByRole('button', { name: 'Stop' }).click();
+ const stopping = page.getByRole('button', { name: 'Stopping…' });
+ await expect.element(stopping).toBeDisabled();
+ // Forced: a disabled control must ignore clicks, which is the behavior under test.
+ await stopping.click({ force: true });
expect(stop).toHaveBeenCalledTimes(1);
expect(stop).toHaveBeenCalledWith({ runId: 'stop-this-run' });
finishStop();
- await vi.waitFor(() =>
- expect(screen.getByRole('button', { name: 'Stop' }).hasAttribute('disabled')).toBe(false)
- );
+ await expect.element(page.getByRole('button', { name: 'Stop' })).toBeEnabled();
});
diff --git a/apps/web/src/features/agents/agents-sidebar-nav.test.tsx b/apps/web/src/features/agents/agents-sidebar-nav.test.tsx
index 8d2bad91..6273a245 100644
--- a/apps/web/src/features/agents/agents-sidebar-nav.test.tsx
+++ b/apps/web/src/features/agents/agents-sidebar-nav.test.tsx
@@ -1,8 +1,8 @@
import { SidebarProvider } from '@groam/ui/components/sidebar';
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
-import { stubMatchMedia } from '@/testing/stub-match-media';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { AgentsSidebarNav } from './agents-sidebar-nav';
const state = vi.hoisted(() => ({
@@ -10,7 +10,8 @@ const state = vi.hoisted(() => ({
pathname: '/agents'
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useLocation: () => ({ pathname: state.pathname })
}));
@@ -19,7 +20,7 @@ vi.mock('./hooks/use-agent-roster', () => ({
useAgentRoster: () => ({ agents: state.agents })
}));
-function renderNav() {
+async function renderNav() {
return render(
@@ -33,21 +34,17 @@ beforeEach(() => {
vi.clearAllMocks();
state.agents = [];
state.pathname = '/agents';
- stubMatchMedia();
});
-afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
+test('links to the agents roster', async () => {
+ await renderNav();
+ await expect
+ .element(page.getByRole('link', { name: 'Agents' }))
+ .toHaveAttribute('href', '/agents');
});
-test('links to the agents roster', () => {
- renderNav();
- expect(screen.getByRole('link', { name: 'Agents' }).getAttribute('href')).toBe('/agents');
-});
-
-test('shows an active-run count when workers are busy', () => {
+test('shows an active-run count when workers are busy', async () => {
state.agents = [{ activeRunCount: 2 }, { activeRunCount: 1 }];
- renderNav();
- expect(screen.getByText('3')).toBeTruthy();
+ await renderNav();
+ await expect.element(page.getByText('3')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/agents/hooks/use-agent-roster.test.ts b/apps/web/src/features/agents/hooks/use-agent-roster.test.ts
index 70189205..e6b1d8ed 100644
--- a/apps/web/src/features/agents/hooks/use-agent-roster.test.ts
+++ b/apps/web/src/features/agents/hooks/use-agent-roster.test.ts
@@ -1,10 +1,11 @@
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useAgentRoster } from './use-agent-roster';
const convex = vi.hoisted(() => ({ useQuery: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -12,18 +13,18 @@ beforeEach(() => {
vi.clearAllMocks();
});
-test('exposes loading until the agent roster is available', () => {
+test('exposes loading until the agent roster is available', async () => {
convex.useQuery.mockReturnValue(undefined);
- const { result } = renderHook(() => useAgentRoster());
+ const { result } = await renderHook(() => useAgentRoster());
expect(result.current).toEqual({ agents: undefined, isLoading: true });
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), {});
});
-test('returns the resolved roster without a loading state', () => {
+test('returns the resolved roster without a loading state', async () => {
const agents = [{ id: 'trip-planner', name: 'Trip planner' }];
convex.useQuery.mockReturnValue(agents);
- const { result } = renderHook(() => useAgentRoster());
+ const { result } = await renderHook(() => useAgentRoster());
expect(result.current).toEqual({ agents, isLoading: false });
});
diff --git a/apps/web/src/features/agents/hooks/use-agent-run.test.ts b/apps/web/src/features/agents/hooks/use-agent-run.test.ts
index 11a611c6..da32afbf 100644
--- a/apps/web/src/features/agents/hooks/use-agent-run.test.ts
+++ b/apps/web/src/features/agents/hooks/use-agent-run.test.ts
@@ -1,6 +1,6 @@
import type { Id } from '@groam/backend/data-model';
import { toast } from '@groam/ui/components/toast';
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import {
useAgentRun,
@@ -20,7 +20,8 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useAction: (...args: unknown[]) => convex.useAction(...args),
useMutation: (...args: unknown[]) => convex.useMutation(...args),
usePaginatedQuery: (...args: unknown[]) => convex.usePaginatedQuery(...args),
@@ -46,16 +47,16 @@ beforeEach(() => {
convex.useQuery.mockReturnValue({ id: 'run-1' });
});
-test('loads events in display order and skips run queries without an id', () => {
- const events = renderHook(() => useAgentRunEvents('run-1' as Id<'agentRuns'>));
+test('loads events in display order and skips run queries without an id', async () => {
+ const events = await renderHook(() => useAgentRunEvents('run-1' as Id<'agentRuns'>));
expect(events.result.current.events).toEqual([{ id: 'old' }, { id: 'new' }]);
expect(events.result.current.status).toBe('CanLoadMore');
- const issue = renderHook(() => useIssueAgentRun(undefined));
+ const issue = await renderHook(() => useIssueAgentRun(undefined));
expect(issue.result.current).toEqual({ id: 'run-1' });
expect(convex.useQuery).toHaveBeenLastCalledWith(expect.anything(), 'skip');
- const run = renderHook(() => useAgentRun('run-1' as Id<'agentRuns'>));
+ const run = await renderHook(() => useAgentRun('run-1' as Id<'agentRuns'>));
expect(run.result.current).toEqual({ id: 'run-1' });
expect(convex.useQuery).toHaveBeenLastCalledWith(expect.anything(), { runId: 'run-1' });
});
@@ -84,13 +85,13 @@ test('starts each eligible queued assignment once and ignores other runs', async
{ id: 'chat' as Id<'agentRuns'>, kickoff: 'assign', status: 'queued', surface: 'chat' }
]
};
- const { rerender } = renderHook(
+ const { rerender } = await renderHook(
({ runs }: { runs: AssignRun[] | undefined }) => useStartQueuedAssignRuns(runs),
{ initialProps }
);
- await waitFor(() => expect(convex.start).toHaveBeenCalledWith({ runId: 'queued' }));
- rerender({
+ await vi.waitFor(() => expect(convex.start).toHaveBeenCalledWith({ runId: 'queued' }));
+ await rerender({
runs: [
{
id: 'queued' as Id<'agentRuns'>,
@@ -101,12 +102,12 @@ test('starts each eligible queued assignment once and ignores other runs', async
]
});
expect(convex.start).toHaveBeenCalledTimes(1);
- rerender({ runs: undefined });
+ await rerender({ runs: undefined });
expect(convex.start).toHaveBeenCalledTimes(1);
});
test('retries from zero by queueing a new run then starting it', async () => {
- const { result } = renderHook(() => useAgentRunControls());
+ const { result } = await renderHook(() => useAgentRunControls());
let nextId: string | undefined;
await act(async () => {
nextId = await result.current.retry({ runId: 'run-failed' as Id<'agentRuns'> });
@@ -118,7 +119,7 @@ test('retries from zero by queueing a new run then starting it', async () => {
test('toasts when retry from zero fails', async () => {
convex.retry.mockRejectedValue(new Error('Stop this run before re-running'));
- const { result } = renderHook(() => useAgentRunControls());
+ const { result } = await renderHook(() => useAgentRunControls());
let nextId: string | undefined = 'sentinel';
await act(async () => {
nextId = await result.current.retry({ runId: 'run-failed' as Id<'agentRuns'> });
diff --git a/apps/web/src/features/agents/hooks/use-agent.test.ts b/apps/web/src/features/agents/hooks/use-agent.test.ts
index 6c99bb91..c09a6377 100644
--- a/apps/web/src/features/agents/hooks/use-agent.test.ts
+++ b/apps/web/src/features/agents/hooks/use-agent.test.ts
@@ -1,5 +1,5 @@
import type { AssistantAgentId } from '@groam/ai/agents';
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useAgent, useAgentRecord } from './use-agent';
@@ -8,7 +8,8 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
usePaginatedQuery: (...args: unknown[]) => convex.usePaginatedQuery(...args),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -23,8 +24,8 @@ beforeEach(() => {
});
});
-test('skips both queries until an agent is selected', () => {
- const { result } = renderHook(() => useAgent(undefined));
+test('skips both queries until an agent is selected', async () => {
+ const { result } = await renderHook(() => useAgent(undefined));
expect(result.current.agent).toBeUndefined();
expect(result.current.runs).toEqual([]);
@@ -34,7 +35,7 @@ test('skips both queries until an agent is selected', () => {
});
});
-test('returns an agent record and its paginated runs', () => {
+test('returns an agent record and its paginated runs', async () => {
const agentId = 'trip-planner' as AssistantAgentId;
const loadMore = vi.fn();
const agent = { id: agentId, name: 'Trip planner' };
@@ -42,7 +43,7 @@ test('returns an agent record and its paginated runs', () => {
convex.useQuery.mockReturnValue(agent);
convex.usePaginatedQuery.mockReturnValue({ loadMore, results: runs, status: 'CanLoadMore' });
- const { result } = renderHook(() => useAgent(agentId));
+ const { result } = await renderHook(() => useAgent(agentId));
expect(result.current).toEqual({ agent, loadMore, runs, status: 'CanLoadMore' });
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), { agentId });
@@ -55,9 +56,9 @@ test('returns an agent record and its paginated runs', () => {
);
});
-test('normalizes a null agent record to undefined', () => {
+test('normalizes a null agent record to undefined', async () => {
convex.useQuery.mockReturnValue(null);
- const { result } = renderHook(() => useAgentRecord('trip-planner' as AssistantAgentId));
+ const { result } = await renderHook(() => useAgentRecord('trip-planner' as AssistantAgentId));
expect(result.current).toBeUndefined();
});
diff --git a/apps/web/src/features/agents/hooks/use-workspace-agent-runs.test.ts b/apps/web/src/features/agents/hooks/use-workspace-agent-runs.test.ts
index 0175b8b3..80543217 100644
--- a/apps/web/src/features/agents/hooks/use-workspace-agent-runs.test.ts
+++ b/apps/web/src/features/agents/hooks/use-workspace-agent-runs.test.ts
@@ -1,10 +1,11 @@
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useWorkspaceAgentRuns } from './use-workspace-agent-runs';
const convex = vi.hoisted(() => ({ usePaginatedQuery: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
usePaginatedQuery: (...args: unknown[]) => convex.usePaginatedQuery(...args)
}));
@@ -17,8 +18,8 @@ beforeEach(() => {
});
});
-test('uses the supplied page size and exposes the first-page loading state', () => {
- const { result } = renderHook(() => useWorkspaceAgentRuns({ initialNumItems: 10 }));
+test('uses the supplied page size and exposes the first-page loading state', async () => {
+ const { result } = await renderHook(() => useWorkspaceAgentRuns({ initialNumItems: 10 }));
expect(result.current.isLoading).toBe(true);
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(
@@ -30,8 +31,8 @@ test('uses the supplied page size and exposes the first-page loading state', ()
);
});
-test('skips the query while preserving the standard page size', () => {
- const { result } = renderHook(() => useWorkspaceAgentRuns('skip'));
+test('skips the query while preserving the standard page size', async () => {
+ const { result } = await renderHook(() => useWorkspaceAgentRuns('skip'));
expect(result.current).toMatchObject({ isLoading: true, runs: [], status: 'LoadingFirstPage' });
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', {
@@ -39,12 +40,12 @@ test('skips the query while preserving the standard page size', () => {
});
});
-test('returns loaded workspace runs and pagination controls', () => {
+test('returns loaded workspace runs and pagination controls', async () => {
const loadMore = vi.fn();
const runs = [{ id: 'run-1', status: 'running' }];
convex.usePaginatedQuery.mockReturnValue({ loadMore, results: runs, status: 'Exhausted' });
- const { result } = renderHook(() => useWorkspaceAgentRuns());
+ const { result } = await renderHook(() => useWorkspaceAgentRuns());
expect(result.current).toEqual({ isLoading: false, loadMore, runs, status: 'Exhausted' });
});
diff --git a/apps/web/src/features/auth/auth-client-mock.ts b/apps/web/src/features/auth/auth-client-mock.ts
index 61faa9a7..704fa9a9 100644
--- a/apps/web/src/features/auth/auth-client-mock.ts
+++ b/apps/web/src/features/auth/auth-client-mock.ts
@@ -11,24 +11,22 @@ const doubles = vi.hoisted(() => ({
verifyTotp: vi.fn()
}));
-vi.mock('@groam/auth/client', () => ({
- authClient: {
- accountRecovery: {
- generate: doubles.generateRecoveryCodes,
- resetPassword: doubles.resetPasswordWithRecoveryCode
- },
- signIn: {
- email: doubles.signInEmail,
- passkey: doubles.passkey,
- username: doubles.signInUsername
- },
- signUp: { email: doubles.signUp },
- twoFactor: {
- verifyBackupCode: doubles.verifyBackupCode,
- verifyTotp: doubles.verifyTotp
- }
+export const authClientMock = {
+ accountRecovery: {
+ generate: doubles.generateRecoveryCodes,
+ resetPassword: doubles.resetPasswordWithRecoveryCode
+ },
+ signIn: {
+ email: doubles.signInEmail,
+ passkey: doubles.passkey,
+ username: doubles.signInUsername
+ },
+ signUp: { email: doubles.signUp },
+ twoFactor: {
+ verifyBackupCode: doubles.verifyBackupCode,
+ verifyTotp: doubles.verifyTotp
}
-}));
+};
export const auth = {
get recoverAccount() {
diff --git a/apps/web/src/features/auth/auth-screen.test.tsx b/apps/web/src/features/auth/auth-screen.test.tsx
index 566b614d..9390f928 100644
--- a/apps/web/src/features/auth/auth-screen.test.tsx
+++ b/apps/web/src/features/auth/auth-screen.test.tsx
@@ -1,47 +1,52 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { auth } from '@/features/auth/auth-client-mock';
import { useAuthFormStore } from '@/lib/stores/auth-form-store';
import { AuthScreen } from './auth-screen';
+vi.mock('@groam/auth/client', async () => {
+ const { authClientMock } = await import('@/features/auth/auth-client-mock');
+ return { authClient: authClientMock };
+});
+
beforeEach(() => {
useAuthFormStore.getState().reset();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
- cleanup();
vi.restoreAllMocks();
});
describe('AuthScreen', () => {
- test('renders the sign-in form by default', () => {
- render();
+ test('renders the sign-in form by default', async () => {
+ await render();
- expect(screen.getByRole('link', { name: 'Groam home' })).toBeTruthy();
- expect(screen.getByText('Welcome back')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
- expect(screen.queryByLabelText(/^Name/u)).toBeNull();
+ await expect.element(page.getByRole('link', { name: 'Groam home' })).toBeInTheDocument();
+ await expect.element(page.getByText('Welcome back')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
+ await expect.element(page.getByLabelText(/^Name/u)).not.toBeInTheDocument();
});
- test('switches to sign-up and shows the name field', () => {
- render();
+ test('switches to sign-up and shows the name field', async () => {
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
+ await page.getByRole('button', { name: 'Sign up' }).click();
- expect(screen.getByText('Create your account')).toBeTruthy();
- expect(screen.getByLabelText(/^Name/u)).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Create account' })).toBeTruthy();
+ await expect.element(page.getByText('Create your account')).toBeInTheDocument();
+ await expect.element(page.getByLabelText(/^Name/u)).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Create account' })).toBeInTheDocument();
});
test('shows validation feedback when required fields are missing', async () => {
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ await page.getByRole('button', { name: 'Sign in' }).click();
- expect((await screen.findByRole('alert')).textContent).toBe(
- 'Complete every required field to continue.'
- );
+ await expect
+ .element(page.getByRole('alert'))
+ .toHaveTextContent('Complete every required field to continue.');
expect(auth.signIn).not.toHaveBeenCalled();
});
@@ -50,35 +55,25 @@ describe('AuthScreen', () => {
data: null,
error: { message: 'Invalid email or password' }
});
- render();
+ await render();
- fireEvent.change(screen.getByLabelText(/^Username or email/u), {
- target: { value: 'traveler' }
- });
- fireEvent.change(screen.getByLabelText(/^Password/u), {
- target: { value: 'password123' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ await page.getByLabelText(/^Username or email/u).fill('traveler');
+ await page.getByLabelText(/^Password/u).fill('password123');
+ await page.getByRole('button', { name: 'Sign in' }).click();
- await waitFor(() =>
- expect(screen.getByRole('alert').textContent).toBe('Invalid email or password')
- );
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Invalid email or password');
});
test('submits sign-up credentials on the create-account flow', async () => {
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
- fireEvent.change(screen.getByLabelText(/^Name/u), { target: { value: 'Traveler' } });
- fireEvent.change(screen.getByLabelText(/^Username/u), {
- target: { value: 'traveler' }
- });
- fireEvent.change(screen.getByLabelText(/^Password/u), {
- target: { value: 'password123' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Create account' }));
+ await page.getByRole('button', { name: 'Sign up' }).click();
+ await page.getByLabelText(/^Name/u).fill('Traveler');
+ await page.getByLabelText(/^Username/u).fill('traveler');
+ await page.getByLabelText(/^Password/u).fill('password123');
+ await page.getByRole('button', { name: 'Create account' }).click();
- await waitFor(() =>
+ await vi.waitFor(() =>
expect(auth.signUp).toHaveBeenCalledWith({
email: expect.stringMatching(/@users\.invalid$/u),
name: 'Traveler',
@@ -88,13 +83,13 @@ describe('AuthScreen', () => {
);
});
- test('shows the saved-code recovery form', () => {
- render();
+ test('shows the saved-code recovery form', async () => {
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Recover account with a saved code' }));
+ await page.getByRole('button', { name: 'Recover account with a saved code' }).click();
- expect(screen.getByText('Recover your account')).toBeTruthy();
- expect(screen.getByLabelText(/^Saved recovery code/u)).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Reset password' })).toBeTruthy();
+ await expect.element(page.getByText('Recover your account')).toBeInTheDocument();
+ await expect.element(page.getByLabelText(/^Saved recovery code/u)).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Reset password' })).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/auth/hooks/use-auth-form.test.tsx b/apps/web/src/features/auth/hooks/use-auth-form.test.tsx
index 72227013..271a7d07 100644
--- a/apps/web/src/features/auth/hooks/use-auth-form.test.tsx
+++ b/apps/web/src/features/auth/hooks/use-auth-form.test.tsx
@@ -1,23 +1,32 @@
-import { act, renderHook } from '@testing-library/react';
-import { beforeEach, describe, expect, test } from 'vite-plus/test';
+import { act, renderHook } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { auth } from '@/features/auth/auth-client-mock';
import { useAuthFormStore } from '@/lib/stores/auth-form-store';
import { useAuthForm } from './use-auth-form';
+vi.mock('@groam/auth/client', async () => {
+ const { authClientMock } = await import('@/features/auth/auth-client-mock');
+ return { authClient: authClientMock };
+});
+
describe('useAuthForm', () => {
- beforeEach(() => useAuthFormStore.getState().reset());
+ beforeEach(() => {
+ useAuthFormStore.getState().reset();
+ });
test('signs in with username and password', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ identifier: 'traveler', password: 'password123' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
+ result.current.updateState({ identifier: 'traveler', password: 'password123' })
+ );
await act(() => result.current.submit());
expect(auth.signIn).toHaveBeenCalledWith({ password: 'password123', username: 'traveler' });
expect(result.current.state).toMatchObject({ error: null, isPending: false });
});
test('keeps legacy email sign-in working', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() =>
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
result.current.updateState({ identifier: 'traveler@example.com', password: 'password123' })
);
await act(() => result.current.submit());
@@ -28,8 +37,8 @@ describe('useAuthForm', () => {
});
test('signs up with a username and internal placeholder email', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() =>
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
result.current.updateState({
flow: 'signUp',
identifier: 'traveler',
@@ -47,16 +56,16 @@ describe('useAuthForm', () => {
});
test('rejects incomplete fields without calling auth', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ identifier: 'traveler' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.updateState({ identifier: 'traveler' }));
await act(() => result.current.submit());
expect(auth.signIn).not.toHaveBeenCalled();
expect(result.current.state.error).toBe('Complete every required field to continue.');
});
test('rejects passwords shorter than the shared minimum', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ identifier: 'traveler', password: 'short' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.updateState({ identifier: 'traveler', password: 'short' }));
await act(() => result.current.submit());
@@ -65,8 +74,8 @@ describe('useAuthForm', () => {
});
test('rejects invalid sign-up usernames', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() =>
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
result.current.updateState({
flow: 'signUp',
identifier: 'not valid',
@@ -80,9 +89,9 @@ describe('useAuthForm', () => {
});
test('resets a forgotten password with a saved recovery code', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.switchToRecovery());
- act(() =>
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.switchToRecovery());
+ await act(() =>
result.current.updateState({
identifier: ' traveler ',
newPassword: 'new-password-123',
@@ -99,13 +108,13 @@ describe('useAuthForm', () => {
});
test('validates recovery fields before calling the provider', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.switchToRecovery());
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.switchToRecovery());
await act(() => result.current.submit());
expect(result.current.state.error).toBe('Complete every required field to continue.');
- act(() =>
+ await act(() =>
result.current.updateState({
identifier: 'traveler',
newPassword: 'short',
@@ -121,9 +130,9 @@ describe('useAuthForm', () => {
test('surfaces recovery and passkey provider errors', async () => {
auth.recoverAccount.mockResolvedValue({ data: null, error: { message: 'Code expired' } });
auth.passkey.mockResolvedValue({ data: null, error: { message: 'Passkey cancelled' } });
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.switchToRecovery());
- act(() =>
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.switchToRecovery());
+ await act(() =>
result.current.updateState({
identifier: 'traveler',
newPassword: 'new-password-123',
@@ -140,18 +149,22 @@ describe('useAuthForm', () => {
test('surfaces sign-in auth errors', async () => {
auth.signIn.mockResolvedValue({ data: null, error: { message: 'Invalid credentials' } });
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ identifier: 'traveler', password: 'password123' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
+ result.current.updateState({ identifier: 'traveler', password: 'password123' })
+ );
await act(() => result.current.submit());
expect(result.current.state).toMatchObject({ error: 'Invalid credentials', isPending: false });
});
test('does not trust the browser after authenticator verification', async () => {
auth.signIn.mockResolvedValue({ data: { twoFactorRedirect: true }, error: null });
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ identifier: 'traveler', password: 'password123' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
+ result.current.updateState({ identifier: 'traveler', password: 'password123' })
+ );
await act(() => result.current.submit());
- act(() => result.current.updateState({ twoFactorCode: '123456' }));
+ await act(() => result.current.updateState({ twoFactorCode: '123456' }));
await act(() => result.current.submit());
expect(auth.verifyTotp).toHaveBeenCalledWith({ code: '123456', trustDevice: false });
@@ -160,8 +173,10 @@ describe('useAuthForm', () => {
});
test('does not trust the browser after backup-code verification', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ needsTwoFactor: true, twoFactorCode: 'ABCDE-12345' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
+ result.current.updateState({ needsTwoFactor: true, twoFactorCode: 'ABCDE-12345' })
+ );
await act(() => result.current.submit());
expect(auth.verifyTotp).not.toHaveBeenCalled();
@@ -173,14 +188,14 @@ describe('useAuthForm', () => {
});
test('requires a two-factor code and surfaces invalid backup codes', async () => {
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ needsTwoFactor: true }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.updateState({ needsTwoFactor: true }));
await act(() => result.current.submit());
expect(result.current.state.error).toBe('Enter an authenticator or backup code.');
auth.verifyBackupCode.mockResolvedValue({ data: null, error: { message: 'Invalid backup' } });
- act(() => result.current.updateState({ twoFactorCode: 'invalid-code' }));
+ await act(() => result.current.updateState({ twoFactorCode: 'invalid-code' }));
await act(() => result.current.submit());
expect(auth.verifyTotp).not.toHaveBeenCalled();
@@ -189,8 +204,8 @@ describe('useAuthForm', () => {
test('does not consume a backup-code attempt when an authenticator code fails', async () => {
auth.verifyTotp.mockResolvedValue({ data: null, error: { message: 'Invalid TOTP' } });
- const { result } = renderHook(() => useAuthForm());
- act(() => result.current.updateState({ needsTwoFactor: true, twoFactorCode: '654321' }));
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() => result.current.updateState({ needsTwoFactor: true, twoFactorCode: '654321' }));
await act(() => result.current.submit());
@@ -204,12 +219,12 @@ describe('useAuthForm', () => {
});
});
- test('switchFlow clears the password and error', () => {
- const { result } = renderHook(() => useAuthForm());
- act(() =>
+ test('switchFlow clears the password and error', async () => {
+ const { result } = await renderHook(() => useAuthForm());
+ await act(() =>
result.current.updateState({ error: 'Authentication failed', password: 'password123' })
);
- act(() => result.current.switchFlow());
+ await act(() => result.current.switchFlow());
expect(result.current.isSignIn).toBe(false);
expect(result.current.state).toMatchObject({ error: null, flow: 'signUp', password: '' });
});
diff --git a/apps/web/src/features/auth/hooks/use-session-change.test.tsx b/apps/web/src/features/auth/hooks/use-session-change.test.tsx
new file mode 100644
index 00000000..0bef61e6
--- /dev/null
+++ b/apps/web/src/features/auth/hooks/use-session-change.test.tsx
@@ -0,0 +1,113 @@
+import { act, renderHook } from '@groam/vitest-config/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { useSessionChange } from './use-session-change';
+
+const convexAuth = vi.hoisted(() => ({ isAuthenticated: true, isLoading: false }));
+
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useConvexAuth: () => convexAuth
+}));
+
+beforeEach(() => {
+ convexAuth.isAuthenticated = true;
+ convexAuth.isLoading = false;
+});
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+describe('useSessionChange', () => {
+ test('shows the workspace again when the request returns an error', async () => {
+ const { result } = await renderHook(() => useSessionChange());
+
+ await act(async () => {
+ const outcome = await result.current.changeSession(async () => ({
+ error: { message: 'no' }
+ }));
+ expect(outcome.error).toEqual({ message: 'no' });
+ });
+
+ expect(result.current.isChangingSession).toBe(false);
+ });
+
+ test('shows the workspace again and rethrows when the request throws', async () => {
+ const { result } = await renderHook(() => useSessionChange());
+ const failure = new Error('offline');
+
+ await act(async () => {
+ await expect(
+ result.current.changeSession(async () => {
+ throw failure;
+ })
+ ).rejects.toBe(failure);
+ });
+
+ expect(result.current.isChangingSession).toBe(false);
+ });
+
+ test('hides the workspace while the request runs', async () => {
+ const { result } = await renderHook(() => useSessionChange());
+ let finish: (value: { error: null }) => void = () => {};
+ const request = new Promise<{ error: null }>((resolve) => {
+ finish = resolve;
+ });
+
+ let pending: Promise = Promise.resolve();
+ await act(async () => {
+ pending = result.current.changeSession(() => request);
+ });
+ expect(result.current.isChangingSession).toBe(true);
+
+ await act(async () => {
+ finish({ error: null });
+ await pending;
+ });
+ });
+
+ test('stays hidden after success until Convex starts loading', async () => {
+ const { rerender, result } = await renderHook(() => useSessionChange());
+
+ await act(async () => {
+ await result.current.changeSession(async () => ({ error: null }));
+ });
+ expect(result.current.isChangingSession).toBe(true);
+
+ convexAuth.isLoading = true;
+ await act(async () => {
+ await rerender();
+ });
+ expect(result.current.isChangingSession).toBe(false);
+ });
+
+ test('stays hidden after success until Convex reports no session', async () => {
+ const { rerender, result } = await renderHook(() => useSessionChange());
+
+ await act(async () => {
+ await result.current.changeSession(async () => ({ error: null }));
+ });
+ expect(result.current.isChangingSession).toBe(true);
+
+ convexAuth.isAuthenticated = false;
+ await act(async () => {
+ await rerender();
+ });
+ expect(result.current.isChangingSession).toBe(false);
+ });
+
+ test('returns focus to the control that started a failed request', async () => {
+ const button = document.createElement('button');
+ document.body.append(button);
+ button.focus();
+ const { result } = await renderHook(() => useSessionChange());
+
+ button.blur();
+ await act(async () => {
+ button.focus();
+ await result.current.changeSession(async () => ({ error: { message: 'no' } }));
+ });
+
+ expect(document.activeElement).toBe(button);
+ });
+});
diff --git a/apps/web/src/features/auth/hooks/use-session-change.ts b/apps/web/src/features/auth/hooks/use-session-change.ts
new file mode 100644
index 00000000..3e3afd44
--- /dev/null
+++ b/apps/web/src/features/auth/hooks/use-session-change.ts
@@ -0,0 +1,43 @@
+import { useConvexAuth } from 'convex/react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+// oxlint-disable-next-line react-doctor/no-flush-sync -- The hidden workspace must commit, dropping its subscriptions, before the auth request starts; the app uses no View Transitions.
+import { flushSync } from 'react-dom';
+import type { ChangeSession } from '@/types/account-security';
+
+/**
+ * Pauses the workspace around auth requests that end or replace the session. The server drops the
+ * old session before the client hears of it, so mounted workspace subscriptions would rerun
+ * against a session that is gone and throw "Not authenticated".
+ */
+export function useSessionChange() {
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const [isChangingSession, setChangingSession] = useState(false);
+ const focusedBefore = useRef(null);
+ // Convex has taken over: it is fetching a token for the new session, or there is none.
+ if (isChangingSession && (isLoading || !isAuthenticated)) setChangingSession(false);
+
+ // A failed request keeps the session, so the workspace comes back as it was: give focus back.
+ // After a success Convex remounts the workspace and the old element is gone.
+ useEffect(() => {
+ if (isChangingSession) return;
+ const element = focusedBefore.current;
+ focusedBefore.current = null;
+ if (element instanceof HTMLElement && element.isConnected) element.focus();
+ }, [isChangingSession]);
+
+ const changeSession = useCallback(async (request) => {
+ focusedBefore.current = document.activeElement;
+ // Commit the hidden workspace first so its subscriptions are gone before the request starts.
+ flushSync(() => setChangingSession(true));
+ try {
+ const result = await request();
+ if (result.error) setChangingSession(false);
+ return result;
+ } catch (error: unknown) {
+ setChangingSession(false);
+ throw error;
+ }
+ }, []);
+
+ return { changeSession, isChangingSession };
+}
diff --git a/apps/web/src/features/auth/session-change-context.ts b/apps/web/src/features/auth/session-change-context.ts
new file mode 100644
index 00000000..de5e6914
--- /dev/null
+++ b/apps/web/src/features/auth/session-change-context.ts
@@ -0,0 +1,5 @@
+import { createContext } from 'react';
+import type { ChangeSession } from '@/types/account-security';
+
+/** Outside the app shell a session change is just the request; the shell also pauses the workspace. */
+export const SessionChangeContext = createContext((request) => request());
diff --git a/apps/web/src/features/dashboard/dashboard-view.test.tsx b/apps/web/src/features/dashboard/dashboard-view.test.tsx
index 93f41200..53905b41 100644
--- a/apps/web/src/features/dashboard/dashboard-view.test.tsx
+++ b/apps/web/src/features/dashboard/dashboard-view.test.tsx
@@ -1,5 +1,7 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { expectConsole } from '@groam/vitest-config/console';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripListItem, WorkspaceTripProposal } from '@/features/trips/hooks/use-trips';
import {
mockTripWorkspace,
@@ -10,6 +12,26 @@ import {
import { resetTripWorkspaceView, trips } from '@/features/trips/test-workspace-mocks';
import { DashboardView } from './dashboard-view';
+vi.mock('@groam/ai/ui/context/agent-context', async () => {
+ const { agentContextModule } = await import('@/features/trips/test-workspace-mocks');
+ return agentContextModule;
+});
+
+vi.mock('@/features/workspace/workspace-shell/workspace-state', async () => {
+ const { workspaceStateModule } = await import('@/features/trips/test-workspace-mocks');
+ return workspaceStateModule;
+});
+
+vi.mock('@/features/trips/hooks/use-trips', async () => {
+ const { useTripsModule } = await import('@/features/trips/test-workspace-mocks');
+ return useTripsModule;
+});
+
+vi.mock('@tanstack/react-router', async () => {
+ const { tripRouterModule } = await import('@/features/trips/test-workspace-mocks');
+ return tripRouterModule();
+});
+
const tripsUi = vi.hoisted(() => ({
openCreate: vi.fn()
}));
@@ -23,28 +45,28 @@ beforeEach(() => {
tripsUi.openCreate.mockClear();
});
-afterEach(cleanup);
-
describe('DashboardView', () => {
test('shows home layout chrome while trips and ideas load', async () => {
mockTripWorkspace(trips, { isLoading: true });
- render();
+ await render();
- expect(await screen.findByRole('status', { name: 'Loading your workspace…' })).toBeTruthy();
+ await expect
+ .element(page.getByRole('status', { name: 'Loading your workspace…' }))
+ .toBeInTheDocument();
expect(document.querySelectorAll('[data-slot="page-loading"]')).toHaveLength(1);
expect(document.querySelector('[data-slot="skeleton"]')).toBeNull();
});
test('invites the group to start a trip when home is empty', async () => {
mockTripWorkspace(trips, { tripItems: [trip({ archivedAt: now, name: 'Last year' })] });
- render();
+ await render();
- expect(await screen.findByTestId('empty-screen')).toBeTruthy();
- expect(screen.getByText('Where should we go next?')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Create a trip' }));
+ await expect.element(page.getByTestId('empty-screen')).toBeInTheDocument();
+ await expect.element(page.getByText('Where should we go next?')).toBeInTheDocument();
+ await page.getByRole('button', { name: 'Create a trip' }).click();
expect(tripsUi.openCreate).toHaveBeenCalledTimes(1);
- expect(screen.queryByRole('link', { name: 'View trips' })).toBeNull();
- expect(screen.queryByText('Atlantic week')).toBeNull();
+ await expect.element(page.getByRole('link', { name: 'View trips' })).not.toBeInTheDocument();
+ await expect.element(page.getByText('Atlantic week')).not.toBeInTheDocument();
});
test('surfaces waiting ideas, trips, drafts, and settled work', async () => {
@@ -72,27 +94,35 @@ describe('DashboardView', () => {
})
]
});
- render();
+ await render();
- expect(screen.getByText('Acme Labs')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Create trip' }));
+ await expect.element(page.getByText('Acme Labs')).toBeInTheDocument();
+ await page.getByRole('button', { name: 'Create trip' }).click();
expect(tripsUi.openCreate).toHaveBeenCalledTimes(1);
- expect(await screen.findByRole('heading', { name: 'Atlantic week' })).toBeTruthy();
- expect(screen.getByText('Please review the coast day')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Review idea' })).toBeTruthy();
- expect(screen.getByText('Packing list')).toBeTruthy();
- expect(screen.getByText('Train tickets')).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Open dates' })).toBeTruthy();
- expect(screen.getByText('Agree on the travel period')).toBeTruthy();
- expect(screen.queryByText('Decide now')).toBeNull();
- expect(screen.getByRole('heading', { name: 'Needs your attention' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Recent ideas' })).toBeTruthy();
- expect(screen.getByText('Based on the 3 most recent ideas.')).toBeTruthy();
- expect(screen.getByLabelText('Recent idea status distribution')).toBeTruthy();
- expect(screen.getByText('1 item needs attention')).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Continue planning' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Your ideas' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Recently decided' })).toBeTruthy();
+ await expect.element(page.getByRole('heading', { name: 'Atlantic week' })).toBeInTheDocument();
+ await expect.element(page.getByText('Please review the coast day')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Review idea' })).toBeInTheDocument();
+ await expect.element(page.getByText('Packing list')).toBeInTheDocument();
+ await expect.element(page.getByText('Train tickets')).toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Open dates' })).toBeInTheDocument();
+ await expect.element(page.getByText('Agree on the travel period')).toBeInTheDocument();
+ await expect.element(page.getByText('Decide now')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Needs your attention' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Recent ideas' })).toBeInTheDocument();
+ await expect.element(page.getByText('Based on the 3 most recent ideas.')).toBeInTheDocument();
+ await expect
+ .element(page.getByLabelText('Recent idea status distribution'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('1 item needs attention')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Continue planning' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Your ideas' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Recently decided' }))
+ .toBeInTheDocument();
});
test('caps review requests and dashboard lists without implying workspace totals', async () => {
@@ -111,26 +141,35 @@ describe('DashboardView', () => {
})
)
});
- render();
-
- expect(await screen.findByText('Review request 1')).toBeTruthy();
- expect(screen.getByText('Review request 3')).toBeTruthy();
- expect(screen.queryByText('Review request 4')).toBeNull();
- expect(screen.getByRole('heading', { name: 'Planning trip 4' })).toBeTruthy();
- expect(screen.queryByRole('heading', { name: 'Planning trip 5' })).toBeNull();
- expect(screen.queryByText(/6 active trips/i)).toBeNull();
+ await render();
+
+ await expect.element(page.getByText('Review request 1')).toBeInTheDocument();
+ await expect.element(page.getByText('Review request 3')).toBeInTheDocument();
+ await expect.element(page.getByText('Review request 4')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Planning trip 4' }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByRole('heading', { name: 'Planning trip 5' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByText(/6 active trips/i)).not.toBeInTheDocument();
});
test('shows a recovery screen when workspace queries throw', async () => {
- vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ // React reports the error the recovery boundary catches.
+ expectConsole('error', 'query failed');
trips.useTrips.mockImplementation(() => {
throw new Error('query failed');
});
- render();
+ await render();
- expect(await screen.findByText('Something went wrong')).toBeTruthy();
- expect(screen.getByText('The workspace home could not be loaded.')).toBeTruthy();
- expect(screen.getByText('Open trips to keep planning, or try again.')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy();
+ await expect.element(page.getByText('Something went wrong')).toBeInTheDocument();
+ await expect
+ .element(page.getByText('The workspace home could not be loaded.'))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByText('Open trips to keep planning, or try again.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/dashboard/hooks/use-dashboard.test.tsx b/apps/web/src/features/dashboard/hooks/use-dashboard.test.tsx
index be148c61..a9066323 100644
--- a/apps/web/src/features/dashboard/hooks/use-dashboard.test.tsx
+++ b/apps/web/src/features/dashboard/hooks/use-dashboard.test.tsx
@@ -1,4 +1,4 @@
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useDashboard } from './use-dashboard';
@@ -28,13 +28,13 @@ beforeEach(() => {
});
describe('useDashboard', () => {
- test('presents the empty home action when there is no trip data', () => {
- const { result } = renderHook(() => useDashboard());
+ test('presents the empty home action when there is no trip data', async () => {
+ const { result } = await renderHook(() => useDashboard());
expect(result.current.presentation).toEqual({ kind: 'empty' });
expect(agent.useSetAgentContext).toHaveBeenCalled();
});
- test('presents waiting reviews without changing the dashboard data contract', () => {
+ test('presents waiting reviews without changing the dashboard data contract', async () => {
trips.useTrips.mockReturnValue({
isLoading: false,
trips: [
@@ -59,7 +59,7 @@ describe('useDashboard', () => {
]
});
- const { result } = renderHook(() => useDashboard());
+ const { result } = await renderHook(() => useDashboard());
expect(result.current.presentation.kind).toBe('ready');
});
});
diff --git a/apps/web/src/features/discussions/chat/chat-workspace.test.tsx b/apps/web/src/features/discussions/chat/chat-workspace.test.tsx
index bd9b3556..3b86efd2 100644
--- a/apps/web/src/features/discussions/chat/chat-workspace.test.tsx
+++ b/apps/web/src/features/discussions/chat/chat-workspace.test.tsx
@@ -1,6 +1,7 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { DiscussionListItem } from '@/features/discussions/hooks/use-discussions';
import { ChatWorkspace } from './chat-workspace';
@@ -20,7 +21,8 @@ vi.mock('@/features/workspace/workspace-shell/workspace-state', () => ({
})
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
{children}
@@ -43,26 +45,24 @@ vi.mock('@/features/trips/hooks/use-trips', () => ({
useTrips: () => ({ trips: [] })
}));
-afterEach(cleanup);
-
beforeEach(() => {
state.discussions = [];
state.isLoading = false;
state.params = {};
});
-test('shows an empty inbox when the group has no chats', () => {
- render(
+test('shows an empty inbox when the group has no chats', async () => {
+ await render(
thread
);
- expect(screen.getByText('No chats yet')).toBeTruthy();
- expect(screen.getByText('Your messages')).toBeTruthy();
- expect(screen.getAllByRole('button', { name: 'New chat' })).toHaveLength(1);
+ await expect.element(page.getByText('No chats yet')).toBeInTheDocument();
+ await expect.element(page.getByText('Your messages')).toBeInTheDocument();
+ expect(page.getByRole('button', { name: 'New chat' }).elements()).toHaveLength(1);
});
-test('lists chats and keeps the thread pane empty until one is open', () => {
+test('lists chats and keeps the thread pane empty until one is open', async () => {
state.discussions = [
{
id: 'discussion-1',
@@ -73,18 +73,18 @@ test('lists chats and keeps the thread pane empty until one is open', () => {
updatedAt: Date.now()
} as DiscussionListItem
];
- render(
+ await render(
thread
);
- expect(screen.getByText('Italy dates')).toBeTruthy();
- expect(screen.getByText('Ada: Airport rides?')).toBeTruthy();
- expect(screen.queryByText('thread')).toBeNull();
- expect(screen.getByText('Your messages')).toBeTruthy();
+ await expect.element(page.getByText('Italy dates')).toBeInTheDocument();
+ await expect.element(page.getByText('Ada: Airport rides?')).toBeInTheDocument();
+ await expect.element(page.getByText('thread')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Your messages')).toBeInTheDocument();
});
-test('renders the open thread when a discussion is selected', () => {
+test('renders the open thread when a discussion is selected', async () => {
state.params = { discussionId: 'discussion-1' };
state.discussions = [
{
@@ -96,10 +96,10 @@ test('renders the open thread when a discussion is selected', () => {
updatedAt: Date.now()
} as DiscussionListItem
];
- render(
+ await render(
thread
);
- expect(screen.getByText('thread')).toBeTruthy();
+ await expect.element(page.getByText('thread')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/discussions/chats-sidebar-nav.test.tsx b/apps/web/src/features/discussions/chats-sidebar-nav.test.tsx
index 3250223b..2725e3db 100644
--- a/apps/web/src/features/discussions/chats-sidebar-nav.test.tsx
+++ b/apps/web/src/features/discussions/chats-sidebar-nav.test.tsx
@@ -1,10 +1,10 @@
import { SidebarProvider } from '@groam/ui/components/sidebar';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { DiscussionListItem } from '@/features/discussions/hooks/use-discussions';
import { resetSidebarChromeStore, useSidebarChromeStore } from '@/lib/stores/sidebar-chrome-store';
-import { stubMatchMedia } from '@/testing/stub-match-media';
import { ChatsSidebarNav } from './chats-sidebar-nav';
const state = vi.hoisted(() => ({
@@ -13,7 +13,8 @@ const state = vi.hoisted(() => ({
pathname: '/chat'
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
{children}
@@ -37,7 +38,7 @@ vi.mock('@/features/trips/hooks/use-trips', () => ({
useTrips: () => ({ trips: [] })
}));
-function renderNav() {
+async function renderNav() {
return render(
@@ -54,26 +55,22 @@ beforeEach(() => {
state.discussions = [];
state.isLoading = false;
state.pathname = '/chat';
- stubMatchMedia();
});
-afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
-});
-
-test('offers a new chat item when the inbox is empty', () => {
- renderNav();
- expect(screen.queryByRole('button', { name: 'New chat' })).toBeNull();
- fireEvent.click(screen.getByText('New chat'));
- expect(
- screen.getByText(
- 'Start a shared conversation with people in your group. Mention @groam if you want AI help in the thread.'
+test('offers a new chat item when the inbox is empty', async () => {
+ await renderNav();
+ await expect.element(page.getByRole('button', { name: 'New chat' })).not.toBeInTheDocument();
+ await page.getByText('New chat').click();
+ await expect
+ .element(
+ page.getByText(
+ 'Start a shared conversation with people in your group. Mention @groam if you want AI help in the thread.'
+ )
)
- ).toBeTruthy();
+ .toBeInTheDocument();
});
-test('collapses listed chats from the parent action without leaving the index link', () => {
+test('collapses listed chats from the parent action without leaving the index link', async () => {
state.discussions = [
{
id: 'discussion-1',
@@ -85,19 +82,19 @@ test('collapses listed chats from the parent action without leaving the index li
}
] as unknown as DiscussionListItem[];
- renderNav();
- expect(screen.getByText('Chat 1')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Chat' }).getAttribute('href')).toBe('/chat');
+ await renderNav();
+ await expect.element(page.getByText('Chat 1')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Chat' })).toHaveAttribute('href', '/chat');
- fireEvent.click(screen.getByRole('link', { name: 'Chat' }));
- expect(screen.getByText('Chat 1')).toBeTruthy();
+ await page.getByRole('link', { name: 'Chat' }).click();
+ await expect.element(page.getByText('Chat 1')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Collapse chat' }));
- expect(screen.queryByText('Chat 1')).toBeNull();
- expect(screen.getByRole('button', { name: 'Expand chat' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Collapse chat' }).click();
+ await expect.element(page.getByText('Chat 1')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Expand chat' })).toBeInTheDocument();
});
-test('lists chats and can reveal more than the first page', () => {
+test('lists chats and can reveal more than the first page', async () => {
state.discussions = Array.from({ length: 6 }, (_, index) => ({
id: `discussion-${index}`,
lastMessage: null,
@@ -107,11 +104,11 @@ test('lists chats and can reveal more than the first page', () => {
updatedAt: index
})) as unknown as DiscussionListItem[];
- renderNav();
- expect(screen.getByText('Chat 1')).toBeTruthy();
- expect(screen.getByText('Chat 5')).toBeTruthy();
- expect(screen.queryByText('Chat 6')).toBeNull();
+ await renderNav();
+ await expect.element(page.getByText('Chat 1')).toBeInTheDocument();
+ await expect.element(page.getByText('Chat 5')).toBeInTheDocument();
+ await expect.element(page.getByText('Chat 6')).not.toBeInTheDocument();
- fireEvent.click(screen.getByText('Load more'));
- expect(screen.getByText('Chat 6')).toBeTruthy();
+ await page.getByText('Load more').click();
+ await expect.element(page.getByText('Chat 6')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/discussions/discussion-create/create-discussion-dialog.test.tsx b/apps/web/src/features/discussions/discussion-create/create-discussion-dialog.test.tsx
index 0acfc9a9..44e4cb78 100644
--- a/apps/web/src/features/discussions/discussion-create/create-discussion-dialog.test.tsx
+++ b/apps/web/src/features/discussions/discussion-create/create-discussion-dialog.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { CreateDiscussionDialog } from './create-discussion-dialog';
const dialog = vi.hoisted(() => ({
@@ -33,33 +34,33 @@ beforeEach(() => {
dialog.rosterLoading = false;
});
-afterEach(cleanup);
-
test('requires a title before creating a chat', async () => {
- render();
- fireEvent.click(screen.getByRole('button', { name: /Ada Lovelace/u }));
- fireEvent.click(screen.getByRole('button', { name: 'Start chat' }));
- expect((await screen.findByRole('alert')).textContent).toBe('Give this chat a name.');
+ await render();
+ await page.getByRole('button', { name: /Ada Lovelace/u }).click();
+ await page.getByRole('button', { name: 'Start chat' }).click();
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Give this chat a name.');
expect(dialog.createDiscussion).not.toHaveBeenCalled();
});
test('requires at least one member', async () => {
- render();
- fireEvent.change(screen.getByLabelText(/Title/u), { target: { value: 'Italy dates' } });
- fireEvent.click(screen.getByRole('button', { name: 'Start chat' }));
- expect((await screen.findByRole('alert')).textContent).toBe('Invite at least one group member.');
+ await render();
+ await page.getByLabelText(/Title/u).fill('Italy dates');
+ await page.getByRole('button', { name: 'Start chat' }).click();
+ await expect
+ .element(page.getByRole('alert'))
+ .toHaveTextContent('Invite at least one group member.');
expect(dialog.createDiscussion).not.toHaveBeenCalled();
});
test('creates a chat and reports the new id', async () => {
const onCreated = vi.fn();
- render();
+ await render();
- fireEvent.change(screen.getByLabelText(/Title/u), { target: { value: ' Italy dates ' } });
- fireEvent.click(screen.getByRole('button', { name: /Ada Lovelace/u }));
- fireEvent.click(screen.getByRole('button', { name: 'Start chat' }));
+ await page.getByLabelText(/Title/u).fill(' Italy dates ');
+ await page.getByRole('button', { name: /Ada Lovelace/u }).click();
+ await page.getByRole('button', { name: 'Start chat' }).click();
- await waitFor(() =>
+ await vi.waitFor(() =>
expect(dialog.createDiscussion).toHaveBeenCalledWith({
clientRequestId: expect.any(String),
memberUserIds: ['user-a'],
@@ -71,18 +72,20 @@ test('creates a chat and reports the new id', async () => {
test('surfaces create failures in the dialog', async () => {
dialog.createDiscussion.mockResolvedValue(null);
- render();
+ await render();
- fireEvent.change(screen.getByLabelText(/Title/u), { target: { value: 'Italy dates' } });
- fireEvent.click(screen.getByRole('button', { name: /Ada Lovelace/u }));
- fireEvent.click(screen.getByRole('button', { name: 'Start chat' }));
+ await page.getByLabelText(/Title/u).fill('Italy dates');
+ await page.getByRole('button', { name: /Ada Lovelace/u }).click();
+ await page.getByRole('button', { name: 'Start chat' }).click();
- expect((await screen.findByRole('alert')).textContent).toBe('Unable to start this chat');
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Unable to start this chat');
});
-test('blocks creating a chat when the group has no members', () => {
+test('blocks creating a chat when the group has no members', async () => {
dialog.members = [];
- render();
- expect(screen.getByText('Invite someone to your group before starting a chat.')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Start chat' })).toHaveProperty('disabled', true);
+ await render();
+ await expect
+ .element(page.getByText('Invite someone to your group before starting a chat.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Start chat' })).toBeDisabled();
});
diff --git a/apps/web/src/features/discussions/discussion-thread/discussion-thread-composer.test.tsx b/apps/web/src/features/discussions/discussion-thread/discussion-thread-composer.test.tsx
index f4c3e983..cc71581b 100644
--- a/apps/web/src/features/discussions/discussion-thread/discussion-thread-composer.test.tsx
+++ b/apps/web/src/features/discussions/discussion-thread/discussion-thread-composer.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page, userEvent } from 'vite-plus/test/browser';
import { DiscussionThreadComposer } from './discussion-thread-composer';
const toastError = vi.hoisted(() => vi.fn());
@@ -32,50 +33,42 @@ beforeEach(() => {
URL.revokeObjectURL = vi.fn();
});
-afterEach(cleanup);
-
test('submits composed text to onSend', async () => {
const onSend = vi.fn().mockResolvedValue(true);
- render(
+ await render(
);
- fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
- target: { value: ' Packing list ' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
+ await page.getByRole('textbox', { name: 'Message' }).fill(' Packing list ');
+ await page.getByRole('button', { name: 'Reply' }).click();
- await waitFor(() => expect(onSend).toHaveBeenCalledWith({ files: [], text: 'Packing list' }));
+ await vi.waitFor(() => expect(onSend).toHaveBeenCalledWith({ files: [], text: 'Packing list' }));
});
test('restores the draft when send fails', async () => {
const onSend = vi.fn().mockResolvedValue(false);
- render(
+ await render(
);
- fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
- target: { value: 'Hold this' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
+ await page.getByRole('textbox', { name: 'Message' }).fill('Hold this');
+ await page.getByRole('button', { name: 'Reply' }).click();
- await waitFor(() => expect(onSend).toHaveBeenCalled());
- expect(screen.getByRole('textbox', { name: 'Message' }).value).toBe(
- 'Hold this'
- );
+ await vi.waitFor(() => expect(onSend).toHaveBeenCalled());
+ await expect.element(page.getByRole('textbox', { name: 'Message' })).toHaveValue('Hold this');
});
test('attaches a voice recording and allows empty text submit', async () => {
const onSend = vi.fn().mockResolvedValue(true);
- render(
+ await render(
);
- fireEvent.click(screen.getByRole('button', { name: 'Record voice note' }));
- expect(screen.getByText('voice.webm')).toBeTruthy();
+ await page.getByRole('button', { name: 'Record voice note' }).click();
+ await expect.element(page.getByText('voice.webm')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
- await waitFor(() =>
+ await page.getByRole('button', { name: 'Reply' }).click();
+ await vi.waitFor(() =>
expect(onSend).toHaveBeenCalledWith({
files: [expect.objectContaining({ name: 'voice.webm' })],
text: ''
@@ -83,24 +76,27 @@ test('attaches a voice recording and allows empty text submit', async () => {
);
});
-test('rejects unsupported attachments', () => {
+test('rejects unsupported attachments', async () => {
const onSend = vi.fn();
- const { container } = render(
+ const { container } = await render(
);
- const input = container.querySelector('input[type="file"]');
- expect(input).toBeTruthy();
- fireEvent.change(input as HTMLInputElement, {
- target: { files: [new File(['nope'], 'notes.exe', { type: 'application/x-msdownload' })] }
- });
+ const input = container.querySelector('input[type="file"]');
+ if (!input) throw new Error('Expected an attachment input');
+ await userEvent.upload(
+ input,
+ new File(['nope'], 'notes.exe', { type: 'application/x-msdownload' })
+ );
expect(toastError).toHaveBeenCalledWith('Upload an image, video, audio file, or PDF');
expect(onSend).not.toHaveBeenCalled();
});
-test('stops Groam from the composer chrome', () => {
+test('stops Groam from the composer chrome', async () => {
const onStop = vi.fn().mockResolvedValue(true);
- render();
+ await render(
+
+ );
- fireEvent.click(screen.getByRole('button', { name: 'Stop Groam' }));
+ await page.getByRole('button', { name: 'Stop Groam' }).click();
expect(onStop).toHaveBeenCalled();
});
diff --git a/apps/web/src/features/discussions/discussion-thread/discussion-thread-header.test.tsx b/apps/web/src/features/discussions/discussion-thread/discussion-thread-header.test.tsx
index a9dbc999..21460424 100644
--- a/apps/web/src/features/discussions/discussion-thread/discussion-thread-header.test.tsx
+++ b/apps/web/src/features/discussions/discussion-thread/discussion-thread-header.test.tsx
@@ -1,6 +1,8 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { DiscussionDetailsSheet } from '@/features/discussions/discussion-sheets/discussion-details-sheet';
import { DiscussionParticipantsSheet } from '@/features/discussions/discussion-sheets/discussion-participants-sheet';
import { DiscussionRenameDialog } from '@/features/discussions/discussion-sheets/discussion-rename-dialog';
@@ -17,7 +19,8 @@ const toast = vi.hoisted(() => ({
success: vi.fn()
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
{children}
@@ -44,25 +47,6 @@ vi.mock('@/features/discussions/hooks/use-discussions', async () => {
};
});
-vi.mock('@groam/ui/components/dropdown-menu', () => ({
- DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
- DropdownMenuContent: ({ children }: { children: ReactNode }) => {children}
,
- DropdownMenuItem: ({
- children,
- onSelect,
- ...props
- }: {
- children: ReactNode;
- onSelect?: () => void;
- }) => (
-
- ),
- DropdownMenuSeparator: () =>
,
- DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}>
-}));
-
const discussion = {
createdBy: { name: 'Ada', userId: 'user-a' },
id: 'discussion-1',
@@ -83,82 +67,84 @@ beforeEach(() => {
vi.clearAllMocks();
hooks.renameDiscussion.mockResolvedValue(true);
hooks.viewerUserId = 'user-a';
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText: vi.fn().mockResolvedValue(undefined) }
- });
+ vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
});
-afterEach(cleanup);
+test('puts thread actions on the far right of the header', async () => {
+ await useViewport(390, 844);
+ await render();
-test('puts thread actions on the far right of the header', () => {
- render();
-
- expect(screen.getByTestId('chat-heading').textContent).toBe('Italy dates');
- expect(screen.getByText('Ada, Grace')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Thread actions' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Back to chats' })).toBeTruthy();
+ await expect.element(page.getByTestId('chat-heading')).toHaveTextContent('Italy dates');
+ await expect.element(page.getByText('Ada, Grace')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Thread actions' })).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Back to chats' })).toBeInTheDocument();
});
test('opens rename, details, participants, and copy link from the overflow menu', async () => {
- render();
-
- expect(screen.getByTestId('chat-action-rename').textContent).toContain('Rename');
- expect(screen.getByTestId('chat-action-details').textContent).toContain('View details');
- expect(screen.getByTestId('chat-action-participants').textContent).toContain('Participants');
- expect(screen.getByTestId('chat-action-copy-link').textContent).toContain('Copy link');
-
- fireEvent.click(screen.getByTestId('chat-action-copy-link'));
- await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled());
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
- `${window.location.origin}/chat/discussion-1`
+ await render();
+
+ await page.getByRole('button', { name: 'Thread actions' }).click();
+ await expect.element(page.getByTestId('chat-action-rename')).toHaveTextContent('Rename');
+ await expect.element(page.getByTestId('chat-action-details')).toHaveTextContent('View details');
+ await expect
+ .element(page.getByTestId('chat-action-participants'))
+ .toHaveTextContent('Participants');
+ await expect.element(page.getByTestId('chat-action-copy-link')).toHaveTextContent('Copy link');
+
+ await page.getByTestId('chat-action-copy-link').click();
+ await vi.waitFor(() =>
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ `${window.location.origin}/chat/discussion-1`
+ )
);
- expect(toast.success).toHaveBeenCalledWith('Chat link copied');
+ await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith('Chat link copied'));
- fireEvent.click(screen.getByTestId('chat-action-rename'));
- expect(screen.getByRole('heading', { name: 'Rename chat' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Thread actions' }).click();
+ await page.getByTestId('chat-action-rename').click();
+ await expect.element(page.getByRole('heading', { name: 'Rename chat' })).toBeInTheDocument();
});
-test('opens details and participants from the menu', () => {
- render();
+test('opens details and participants from the menu', async () => {
+ await render();
- fireEvent.click(screen.getByTestId('chat-action-details'));
- expect(screen.getByRole('heading', { name: 'Chat details' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Thread actions' }).click();
+ await page.getByTestId('chat-action-details').click();
+ await expect.element(page.getByRole('heading', { name: 'Chat details' })).toBeInTheDocument();
});
test('renames the chat from the dialog', async () => {
- render();
+ await render();
- fireEvent.change(screen.getByLabelText(/Name/u), { target: { value: ' Flights ' } });
- fireEvent.click(screen.getByRole('button', { name: 'Save name' }));
+ await page.getByLabelText(/Name/u).fill(' Flights ');
+ await page.getByRole('button', { name: 'Save name' }).click();
- await waitFor(() =>
+ await vi.waitFor(() =>
expect(hooks.renameDiscussion).toHaveBeenCalledWith('discussion-1', 'Flights')
);
});
test('requires a name before renaming', async () => {
- render();
- fireEvent.change(screen.getByLabelText(/Name/u), { target: { value: ' ' } });
- fireEvent.click(screen.getByRole('button', { name: 'Save name' }));
+ await render();
+ await page.getByLabelText(/Name/u).fill(' ');
+ await page.getByRole('button', { name: 'Save name' }).click();
- expect((await screen.findByRole('alert')).textContent).toBe('Give this chat a name.');
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Give this chat a name.');
expect(hooks.renameDiscussion).not.toHaveBeenCalled();
});
-test('shows chat metadata in the details sheet', () => {
- render();
+test('shows chat metadata in the details sheet', async () => {
+ await render();
- expect(screen.getByText('Chat details')).toBeTruthy();
- expect(screen.getByText('Ada')).toBeTruthy();
- expect(screen.getByText('2 people')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Rome weekend' })).toBeTruthy();
+ await expect.element(page.getByText('Chat details')).toBeInTheDocument();
+ await expect.element(page.getByText('Ada')).toBeInTheDocument();
+ await expect.element(page.getByText('2 people')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Rome weekend' })).toBeInTheDocument();
});
-test('lists participants and marks the current viewer', () => {
- render();
+test('lists participants and marks the current viewer', async () => {
+ await render();
- expect(screen.getByText('Ada (you)')).toBeTruthy();
- expect(screen.getByText('Grace')).toBeTruthy();
- expect(screen.getByText('2 people in Italy dates.')).toBeTruthy();
+ await expect.element(page.getByText('Ada (you)')).toBeInTheDocument();
+ await expect.element(page.getByText('Grace')).toBeInTheDocument();
+ await expect.element(page.getByText('2 people in Italy dates.')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/discussions/discussion-thread/discussion-thread-view.test.tsx b/apps/web/src/features/discussions/discussion-thread/discussion-thread-view.test.tsx
index 58cd010a..8708a1b2 100644
--- a/apps/web/src/features/discussions/discussion-thread/discussion-thread-view.test.tsx
+++ b/apps/web/src/features/discussions/discussion-thread/discussion-thread-view.test.tsx
@@ -1,6 +1,7 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { DiscussionMessage } from '@/features/discussions/hooks/use-discussion-thread';
import type { DiscussionListItem } from '@/features/discussions/hooks/use-discussions';
import { DiscussionThreadView } from './discussion-thread-view';
@@ -29,7 +30,8 @@ vi.mock('@groam/ai/ui/chat/assistant-chat-message', () => ({
AssistantChatMessage: ({ message }: { message: { text: string } }) => {message.text}
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
{children}
@@ -130,29 +132,27 @@ beforeEach(() => {
};
});
-afterEach(cleanup);
-
-test('shows a loading state while the chat list is opening', () => {
+test('shows a loading state while the chat list is opening', async () => {
state.isListLoading = true;
- render();
- expect(screen.getByRole('status', { name: 'Opening chat…' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Opening chat…' })).toBeInTheDocument();
});
-test('shows an error when the chat is missing', () => {
+test('shows an error when the chat is missing', async () => {
state.discussions = [];
- render();
- expect(screen.getByText('Chat not found')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Back to chats' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Chat not found')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Back to chats' })).toBeInTheDocument();
});
-test('renders the empty thread state', () => {
- render();
- expect(screen.getByText('Italy dates')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Thread actions' })).toBeTruthy();
- expect(screen.getByText('No messages yet')).toBeTruthy();
+test('renders the empty thread state', async () => {
+ await render();
+ await expect.element(page.getByText('Italy dates')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Thread actions' })).toBeInTheDocument();
+ await expect.element(page.getByText('No messages yet')).toBeInTheDocument();
});
-test('renders user and assistant messages', () => {
+test('renders user and assistant messages', async () => {
state.thread.messages = [
message({
author: { name: 'Ada', userId: 'user-a' },
@@ -166,18 +166,18 @@ test('renders user and assistant messages', () => {
text: 'I can compare flight options.'
})
];
- render();
- expect(screen.getByText('Airport?')).toBeTruthy();
- expect(screen.getAllByText('Ada').length).toBeGreaterThan(0);
- expect(screen.getByText('I can compare flight options.')).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Airport?')).toBeInTheDocument();
+ expect(page.getByText('Ada').elements().length).toBeGreaterThan(0);
+ await expect.element(page.getByText('I can compare flight options.')).toBeInTheDocument();
});
test('forwards composer send and stop to the thread hook', async () => {
state.thread.canStop = true;
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Stop Groam' }));
- fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
+ await page.getByRole('button', { name: 'Stop Groam' }).click();
+ await page.getByRole('button', { name: 'Reply' }).click();
expect(state.stop).toHaveBeenCalled();
expect(state.send).toHaveBeenCalledWith({ text: 'Hello from composer' });
diff --git a/apps/web/src/features/discussions/discussion-thread/thread-message.test.tsx b/apps/web/src/features/discussions/discussion-thread/thread-message.test.tsx
index 57e28e27..e6f992c5 100644
--- a/apps/web/src/features/discussions/discussion-thread/thread-message.test.tsx
+++ b/apps/web/src/features/discussions/discussion-thread/thread-message.test.tsx
@@ -1,6 +1,6 @@
-import { stubPopoverEnvironment } from '@groam/ui/lib/stub-popover-environment';
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { DiscussionMessage } from '@/features/discussions/hooks/use-discussion-thread';
import { ThreadMessage } from './thread-message';
@@ -8,11 +8,6 @@ vi.mock('@groam/ai/ui/chat/assistant-chat-message', () => ({
AssistantChatMessage: ({ message }: { message: { text: string } }) => {message.text}
}));
-vi.mock('@groam/ui/components/dropdown-menu', async () => {
- const { dropdownMenuTestMock } = await import('@/lib/test-mocks/dropdown-menu');
- return dropdownMenuTestMock;
-});
-
function message(partial: Partial = {}): DiscussionMessage {
return {
_creationTime: 1,
@@ -28,18 +23,12 @@ function message(partial: Partial = {}): DiscussionMessage {
} as DiscussionMessage;
}
-beforeEach(() => {
- stubPopoverEnvironment();
-});
-
afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
vi.restoreAllMocks();
});
-test('keeps reaction stamps and edit controls out of the always-visible row', () => {
- render(
+test('keeps reaction stamps and edit controls out of the always-visible row', async () => {
+ await render(
);
- expect(screen.getByText('Hi!')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Add reaction' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Message actions' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Agree' })).toBeNull();
- expect(screen.queryByText('👍')).toBeNull();
+ await expect.element(page.getByText('Hi!')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Add reaction' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Message actions' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Agree' })).not.toBeInTheDocument();
+ await expect.element(page.getByText('👍')).not.toBeInTheDocument();
});
-test('reacts from the stamp picker', () => {
+test('reacts from the stamp picker', async () => {
const onReact = vi.fn();
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Add reaction' }));
- fireEvent.click(screen.getByRole('button', { name: 'Agree' }));
+ await page.getByText('Hi!').hover();
+ await page.getByRole('button', { name: 'Add reaction' }).click();
+ await page.getByRole('button', { name: 'Agree' }).click();
expect(onReact).toHaveBeenCalledWith('👍');
});
-test('toggles an existing reaction chip on the bubble', () => {
+test('toggles an existing reaction chip on the bubble', async () => {
const onReact = vi.fn();
- render(
+ await render(
{
/>
);
- fireEvent.click(screen.getByRole('button', { name: "Let's go" }));
+ await page.getByRole('button', { name: "Let's go" }).click();
expect(onReact).toHaveBeenCalledWith('🎉');
});
-test("does not offer edit or delete on someone else's message", () => {
- render(
+test("does not offer edit or delete on someone else's message", async () => {
+ await render(
{
/>
);
- expect(screen.queryByRole('button', { name: 'Message actions' })).toBeNull();
+ await expect
+ .element(page.getByRole('button', { name: 'Message actions' }))
+ .not.toBeInTheDocument();
});
test('edits a message from the actions menu', async () => {
const onEdit = vi.fn();
- render(
+ await render(
{
/>
);
- fireEvent.click(screen.getByRole('button', { name: 'Message actions' }));
- fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
- fireEvent.change(screen.getByLabelText('Edit message'), { target: { value: 'See you in Rome' } });
- fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await page.getByText('Hi!').hover();
+ await page.getByRole('button', { name: 'Message actions' }).click();
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page.getByLabelText('Edit message').fill('See you in Rome');
+ await page.getByRole('button', { name: 'Save' }).click();
expect(onEdit).toHaveBeenCalledWith('See you in Rome');
});
@@ -115,7 +108,7 @@ test('edits a message from the actions menu', async () => {
test('deletes a message after confirmation', async () => {
const onRemove = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(true);
- render(
+ await render(
{
/>
);
- fireEvent.click(screen.getByRole('button', { name: 'Message actions' }));
- fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ await page.getByText('Hi!').hover();
+ await page.getByRole('button', { name: 'Message actions' }).click();
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
- await waitFor(() => expect(onRemove).toHaveBeenCalled());
+ await vi.waitFor(() => expect(onRemove).toHaveBeenCalled());
});
diff --git a/apps/web/src/features/discussions/hooks/use-discussion-message-actions.test.tsx b/apps/web/src/features/discussions/hooks/use-discussion-message-actions.test.tsx
index f94ad2b9..6ea94aa8 100644
--- a/apps/web/src/features/discussions/hooks/use-discussion-message-actions.test.tsx
+++ b/apps/web/src/features/discussions/hooks/use-discussion-message-actions.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useDiscussionMessageActions } from './use-discussion-message-actions';
@@ -8,7 +8,10 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
const discussionId = 'discussion-1' as Id<'discussions'>;
@@ -19,13 +22,13 @@ beforeEach(() => {
});
describe('useDiscussionMessageActions', () => {
- test('marks the discussion read on mount', () => {
+ test('marks the discussion read on mount', async () => {
const markRead = vi.fn().mockResolvedValue(null);
convex.useMutation
.mockReturnValueOnce(markRead)
.mockReturnValue(vi.fn().mockResolvedValue(null));
- renderHook(() => useDiscussionMessageActions(discussionId));
+ await renderHook(() => useDiscussionMessageActions(discussionId));
expect(markRead).toHaveBeenCalledWith({ discussionId });
});
@@ -41,7 +44,7 @@ describe('useDiscussionMessageActions', () => {
.mockReturnValueOnce(editMessage)
.mockReturnValueOnce(removeMessage);
- const { result } = renderHook(() => useDiscussionMessageActions(discussionId));
+ const { result } = await renderHook(() => useDiscussionMessageActions(discussionId));
await act(async () => {
await result.current.editMessage('m1', 'hello');
@@ -58,23 +61,23 @@ describe('useDiscussionMessageActions', () => {
expect(removeMessage).toHaveBeenCalledWith({ discussionId, messageId: 'm1' });
});
- test('uses an empty reaction list until the reaction query resolves', () => {
+ test('uses an empty reaction list until the reaction query resolves', async () => {
convex.useQuery.mockReturnValue(undefined);
- const { result } = renderHook(() => useDiscussionMessageActions(discussionId));
+ const { result } = await renderHook(() => useDiscussionMessageActions(discussionId));
expect(result.current.reactions).toEqual([]);
});
- test('marks the replacement discussion read when its id changes', () => {
+ test('marks the replacement discussion read when its id changes', async () => {
const markRead = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValue(markRead);
const nextDiscussionId = 'discussion-2' as Id<'discussions'>;
- const { rerender } = renderHook(({ id }) => useDiscussionMessageActions(id), {
+ const { rerender } = await renderHook(({ id }) => useDiscussionMessageActions(id), {
initialProps: { id: discussionId }
});
- rerender({ id: nextDiscussionId });
+ await rerender({ id: nextDiscussionId });
expect(markRead).toHaveBeenNthCalledWith(1, { discussionId });
expect(markRead).toHaveBeenNthCalledWith(2, { discussionId: nextDiscussionId });
diff --git a/apps/web/src/features/discussions/hooks/use-discussion-thread.test.tsx b/apps/web/src/features/discussions/hooks/use-discussion-thread.test.tsx
index 47bb7068..649cdaaa 100644
--- a/apps/web/src/features/discussions/hooks/use-discussion-thread.test.tsx
+++ b/apps/web/src/features/discussions/hooks/use-discussion-thread.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import type { DiscussionMessage } from './use-discussion-thread';
import { useDiscussionThread } from './use-discussion-thread';
@@ -26,7 +26,8 @@ vi.mock('@convex-dev/agent/react', () => ({
})
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useAction: () => deps.respond,
useMutation: () => deps.mutate,
useQuery: () => deps.attachments
@@ -82,21 +83,25 @@ beforeEach(() => {
});
test('refuses to send when the thread is missing or the payload is empty', async () => {
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, null));
+ const { result } = await renderHook(() => useDiscussionThread('discussion-1' as never, null));
await expect(result.current.send({ text: 'Hello' })).resolves.toBe(false);
expect(deps.mutate).not.toHaveBeenCalled();
});
test('refuses empty text without files', async () => {
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
await expect(result.current.send({ text: ' ' })).resolves.toBe(false);
expect(deps.mutate).not.toHaveBeenCalled();
});
test('toasts when more than five files are attached', async () => {
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
const files = Array.from(
{ length: 6 },
(_, index) => new File(['x'], `${index}.png`, { type: 'image/png' })
@@ -108,7 +113,9 @@ test('toasts when more than five files are attached', async () => {
});
test('uploads media then sends and asks Groam to reply', async () => {
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
const file = new File(['img'], 'sunset.png', { type: 'image/png' });
await act(async () => {
@@ -132,7 +139,9 @@ test('uploads media then sends and asks Groam to reply', async () => {
test('aborts send when a media upload fails', async () => {
deps.upload.mockResolvedValue(null);
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
await expect(
result.current.send({
@@ -145,14 +154,18 @@ test('aborts send when a media upload fails', async () => {
test('toasts and returns false when send throws', async () => {
deps.mutate.mockRejectedValue(new Error('offline'));
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
await expect(result.current.send({ text: 'Hello' })).resolves.toBe(false);
expect(toast.error).toHaveBeenCalledWith('offline');
});
test('shows a pending user message until the server copy arrives', async () => {
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
await act(async () => {
await result.current.send({ text: 'On my way' });
@@ -165,13 +178,15 @@ test('shows a pending user message until the server copy arrives', async () => {
});
});
-test('merges attachment query results onto listed messages', () => {
+test('merges attachment query results onto listed messages', async () => {
deps.messages = [userMessage({ id: 'msg-1', text: 'Sent a photo' })];
deps.attachments = {
'msg-1': [{ contentType: 'image/png', name: 'a.png', url: 'https://cdn/a.png' }]
};
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
expect(result.current.messages[0]?.attachments).toEqual([
{ contentType: 'image/png', name: 'a.png', url: 'https://cdn/a.png' }
@@ -189,7 +204,9 @@ test('stops a streaming assistant reply', async () => {
text: 'Let me'
})
];
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
expect(result.current.canStop).toBe(true);
await act(async () => {
@@ -212,7 +229,9 @@ test('toasts when stopping Groam fails', async () => {
})
];
deps.mutate.mockRejectedValue(new Error('busy'));
- const { result } = renderHook(() => useDiscussionThread('discussion-1' as never, 'thread-1'));
+ const { result } = await renderHook(() =>
+ useDiscussionThread('discussion-1' as never, 'thread-1')
+ );
await expect(result.current.stop()).resolves.toBe(false);
expect(toast.error).toHaveBeenCalledWith('busy');
diff --git a/apps/web/src/features/discussions/hooks/use-discussions.test.tsx b/apps/web/src/features/discussions/hooks/use-discussions.test.tsx
index 40cd2577..2747ded1 100644
--- a/apps/web/src/features/discussions/hooks/use-discussions.test.tsx
+++ b/apps/web/src/features/discussions/hooks/use-discussions.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import {
useCreateDiscussion,
@@ -10,7 +10,8 @@ import {
const convex = vi.hoisted(() => ({ mutation: vi.fn(), query: vi.fn() }));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: () => convex.mutation,
useQuery: (...args: unknown[]) => convex.query(...args)
}));
@@ -23,8 +24,8 @@ beforeEach(() => {
});
describe('discussion hooks', () => {
- test('uses empty collections while discussion data is loading or skipped', () => {
- const { result, rerender } = renderHook(
+ test('uses empty collections while discussion data is loading or skipped', async () => {
+ const { result, rerender } = await renderHook(
({ skip }) => useDiscussions(skip ? 'skip' : undefined),
{
initialProps: { skip: false }
@@ -32,15 +33,15 @@ describe('discussion hooks', () => {
);
expect(result.current).toEqual({ discussions: [], isLoading: true });
- rerender({ skip: true });
+ await rerender({ skip: true });
expect(convex.query.mock.calls.at(-1)?.[1]).toBe('skip');
- const roster = renderHook(() => useDiscussionRoster());
+ const roster = await renderHook(() => useDiscussionRoster());
expect(roster.result.current).toEqual({ isLoading: true, members: [] });
});
test('creates a discussion and reports creation failures', async () => {
- const { result } = renderHook(() => useCreateDiscussion());
+ const { result } = await renderHook(() => useCreateDiscussion());
const input = { clientRequestId: 'request-1', memberUserIds: ['user-1'], title: 'Plans' };
await expect(act(() => result.current.createDiscussion(input))).resolves.toBe('discussion-new');
@@ -52,7 +53,7 @@ describe('discussion hooks', () => {
});
test('renames a discussion and returns false when the mutation fails', async () => {
- const { result } = renderHook(() => useRenameDiscussion());
+ const { result } = await renderHook(() => useRenameDiscussion());
await expect(
act(() => result.current.renameDiscussion('discussion-1' as never, 'New title'))
diff --git a/apps/web/src/features/discussions/hooks/use-thread-message-chrome.test.tsx b/apps/web/src/features/discussions/hooks/use-thread-message-chrome.test.tsx
index f6720446..d894194e 100644
--- a/apps/web/src/features/discussions/hooks/use-thread-message-chrome.test.tsx
+++ b/apps/web/src/features/discussions/hooks/use-thread-message-chrome.test.tsx
@@ -1,25 +1,25 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import { useThreadMessageChrome } from './use-thread-message-chrome';
describe('useThreadMessageChrome', () => {
- test('closes the menu when the picker opens and vice versa', () => {
- const { result } = renderHook(() => useThreadMessageChrome());
+ test('closes the menu when the picker opens and vice versa', async () => {
+ const { result } = await renderHook(() => useThreadMessageChrome());
- act(() => {
+ await act(() => {
result.current.setMenuOpen(true);
});
expect(result.current.menuOpen).toBe(true);
expect(result.current.chromeOpen).toBe(true);
- act(() => {
+ await act(() => {
result.current.setPickerOpen(true);
});
expect(result.current.pickerOpen).toBe(true);
expect(result.current.menuOpen).toBe(false);
expect(result.current.chromeOpen).toBe(true);
- act(() => {
+ await act(() => {
result.current.setMenuOpen(true);
});
expect(result.current.menuOpen).toBe(true);
diff --git a/apps/web/src/features/discussions/hooks/use-thread-message-edit.test.tsx b/apps/web/src/features/discussions/hooks/use-thread-message-edit.test.tsx
index 9838d7e7..bbcfdd02 100644
--- a/apps/web/src/features/discussions/hooks/use-thread-message-edit.test.tsx
+++ b/apps/web/src/features/discussions/hooks/use-thread-message-edit.test.tsx
@@ -1,16 +1,16 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test, vi } from 'vite-plus/test';
import { useThreadMessageEdit, useThreadMessageEditing } from './use-thread-message-edit';
describe('useThreadMessageEditing', () => {
- test('toggles the editing boolean', () => {
- const { result } = renderHook(() => useThreadMessageEditing());
+ test('toggles the editing boolean', async () => {
+ const { result } = await renderHook(() => useThreadMessageEditing());
expect(result.current.editing).toBe(false);
- act(() => {
+ await act(() => {
result.current.startEditing();
});
expect(result.current.editing).toBe(true);
- act(() => {
+ await act(() => {
result.current.stopEditing();
});
expect(result.current.editing).toBe(false);
@@ -18,37 +18,37 @@ describe('useThreadMessageEditing', () => {
});
describe('useThreadMessageEdit', () => {
- test('blocks empty saves and submits trimmed text', () => {
+ test('blocks empty saves and submits trimmed text', async () => {
const onSave = vi.fn();
const onCancel = vi.fn();
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useThreadMessageEdit({ initialText: 'hello', onCancel, onSave })
);
- act(() => {
+ await act(() => {
result.current.setDraft(' ');
});
expect(result.current.canSave).toBe(false);
- act(() => {
+ await act(() => {
result.current.submit();
});
expect(onSave).not.toHaveBeenCalled();
- act(() => {
+ await act(() => {
result.current.setDraft(' updated ');
result.current.submit();
});
expect(onSave).toHaveBeenCalledWith('updated');
});
- test('Escape cancels and Cmd+Enter saves', () => {
+ test('Escape cancels and Cmd+Enter saves', async () => {
const onSave = vi.fn();
const onCancel = vi.fn();
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useThreadMessageEdit({ initialText: 'hello', onCancel, onSave })
);
- act(() => {
+ await act(() => {
result.current.onKeyDown({
key: 'Escape',
preventDefault: vi.fn()
@@ -56,7 +56,7 @@ describe('useThreadMessageEdit', () => {
});
expect(onCancel).toHaveBeenCalled();
- act(() => {
+ await act(() => {
result.current.setDraft('next');
result.current.onKeyDown({
key: 'Enter',
diff --git a/apps/web/src/features/group/group-invitations/invitations-section.test.tsx b/apps/web/src/features/group/group-invitations/invitations-section.test.tsx
index e3747805..4555b8df 100644
--- a/apps/web/src/features/group/group-invitations/invitations-section.test.tsx
+++ b/apps/web/src/features/group/group-invitations/invitations-section.test.tsx
@@ -1,10 +1,9 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { InvitationCode } from '@/types/invitation-codes';
import { InvitationsSection } from './invitations-section';
-afterEach(cleanup);
-
const codes = [
{
code: 'ABCD-EFGH-JKLM',
@@ -15,23 +14,25 @@ const codes = [
}
] as InvitationCode[];
-test('renders active codes and lets managers copy or revoke them', () => {
+test('renders active codes and lets managers copy or revoke them', async () => {
const onCopy = vi.fn();
const onRevoke = vi.fn();
- render();
+ await render(
+
+ );
- expect(screen.getByText('ABCD-EFGH-JKLM')).toBeTruthy();
- expect(screen.getByText('member')).toBeTruthy();
- expect(screen.getByLabelText('Search invitation codes')).toBeTruthy();
+ await expect.element(page.getByText('ABCD-EFGH-JKLM')).toBeInTheDocument();
+ await expect.element(page.getByText('member')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search invitation codes')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Copy ABCD-EFGH-JKLM' }));
- fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
+ await page.getByRole('button', { name: 'Copy ABCD-EFGH-JKLM' }).click();
+ await page.getByRole('button', { name: 'Revoke' }).click();
expect(onCopy).toHaveBeenCalledWith('ABCD-EFGH-JKLM');
expect(onRevoke).toHaveBeenCalledWith('invitation-code-a');
});
-test('disables revoke while the code is pending', () => {
- render(
+test('disables revoke while the code is pending', async () => {
+ await render(
{
pendingId={codes[0]!.id}
/>
);
- expect(screen.getByRole('button', { name: 'Revoke' })).toHaveProperty('disabled', true);
+ await expect.element(page.getByRole('button', { name: 'Revoke' })).toBeDisabled();
});
-test('renders nothing when there are no active codes', () => {
- const { container } = render(
+test('renders nothing when there are no active codes', async () => {
+ const { container } = await render(
);
expect(container.innerHTML).toBe('');
diff --git a/apps/web/src/features/group/group-people/group-people-settings.test.tsx b/apps/web/src/features/group/group-people/group-people-settings.test.tsx
index 06b6dedb..177e0f68 100644
--- a/apps/web/src/features/group/group-people/group-people-settings.test.tsx
+++ b/apps/web/src/features/group/group-people/group-people-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { GroupPeopleSettings } from './group-people-settings';
const workspace = vi.hoisted(() => ({
@@ -61,19 +62,17 @@ vi.mock('@/features/group/hooks/use-invitation-codes', () => ({
})
}));
-afterEach(cleanup);
-
describe('GroupPeopleSettings', () => {
- test('lists members and active invitation codes for the active group', () => {
- render();
+ test('lists members and active invitation codes for the active group', async () => {
+ await render();
- expect(screen.getByText('Members')).toBeTruthy();
- expect(
- screen.getByText('Everyone here shares this group’s trips and planning workspace.')
- ).toBeTruthy();
- expect(screen.getByText('Invitation codes')).toBeTruthy();
- expect(screen.getByText('ABCD-EFGH-JKLM')).toBeTruthy();
- expect(screen.getByText('A (you)')).toBeTruthy();
- expect(screen.getByText('@alice')).toBeTruthy();
+ await expect.element(page.getByText('Members')).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Everyone here shares this group’s trips and planning workspace.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Invitation codes')).toBeInTheDocument();
+ await expect.element(page.getByText('ABCD-EFGH-JKLM')).toBeInTheDocument();
+ await expect.element(page.getByText('A (you)')).toBeInTheDocument();
+ await expect.element(page.getByText('@alice')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/group/hooks/use-group-actions.test.tsx b/apps/web/src/features/group/hooks/use-group-actions.test.tsx
index 4a771262..90b170a9 100644
--- a/apps/web/src/features/group/hooks/use-group-actions.test.tsx
+++ b/apps/web/src/features/group/hooks/use-group-actions.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useGroupActions } from './use-group-actions';
@@ -18,7 +18,7 @@ vi.mock('@groam/auth/client', () => ({
}
}));
-function setup() {
+async function setup() {
return renderHook(() =>
useGroupActions({
organizationId: 'organization-a'
@@ -26,12 +26,14 @@ function setup() {
);
}
-beforeEach(() => vi.clearAllMocks());
+beforeEach(() => {
+ vi.clearAllMocks();
+});
describe('useGroupActions', () => {
test('surfaces leave-group errors', async () => {
auth.leave.mockResolvedValue({ data: null, error: { message: 'Owner cannot leave' } });
- const { result } = setup();
+ const { result } = await setup();
await act(() => result.current.leaveGroup());
@@ -42,7 +44,7 @@ describe('useGroupActions', () => {
test('uses a fallback when leaving fails without a message', async () => {
auth.leave.mockResolvedValue({ data: null, error: {} });
- const { result } = setup();
+ const { result } = await setup();
await act(() => result.current.leaveGroup());
@@ -51,7 +53,7 @@ describe('useGroupActions', () => {
test('removes a member and clears the pending state', async () => {
auth.removeMember.mockResolvedValue({ data: {}, error: null });
- const { result } = setup();
+ const { result } = await setup();
await act(() => result.current.removeMember('member-a'));
@@ -65,7 +67,7 @@ describe('useGroupActions', () => {
test('uses the remove-member fallback on an API error', async () => {
auth.removeMember.mockResolvedValue({ error: {} });
- const { result } = setup();
+ const { result } = await setup();
await act(() => result.current.removeMember('member-a'));
@@ -76,7 +78,7 @@ describe('useGroupActions', () => {
auth.updateMemberRole
.mockResolvedValueOnce({ data: {}, error: null })
.mockResolvedValueOnce({ error: { message: 'Role denied' } });
- const { result } = setup();
+ const { result } = await setup();
await act(() => result.current.updateMemberRole('member-a', 'admin'));
expect(auth.updateMemberRole).toHaveBeenCalledWith({
diff --git a/apps/web/src/features/group/hooks/use-invitation-codes.test.tsx b/apps/web/src/features/group/hooks/use-invitation-codes.test.tsx
index fd031fb2..5d087546 100644
--- a/apps/web/src/features/group/hooks/use-invitation-codes.test.tsx
+++ b/apps/web/src/features/group/hooks/use-invitation-codes.test.tsx
@@ -1,10 +1,11 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useInvitationCodes } from './use-invitation-codes';
const convex = vi.hoisted(() => ({ revoke: vi.fn(), useQuery: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: () => convex.revoke,
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -20,8 +21,8 @@ afterEach(() => {
});
describe('useInvitationCodes', () => {
- test('skips the list query when invitation management is disabled', () => {
- const { result } = renderHook(() => useInvitationCodes(false));
+ test('skips the list query when invitation management is disabled', async () => {
+ const { result } = await renderHook(() => useInvitationCodes(false));
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), 'skip');
expect(result.current).toMatchObject({ codes: [], isLoading: false, pendingId: null });
@@ -30,7 +31,7 @@ describe('useInvitationCodes', () => {
test('lists enabled codes and revokes a selected code', async () => {
const codes = [{ id: 'invite-1', code: 'TRIP', expiresAt: null }];
convex.useQuery.mockReturnValue(codes);
- const { result } = renderHook(() => useInvitationCodes(true));
+ const { result } = await renderHook(() => useInvitationCodes(true));
expect(convex.useQuery).toHaveBeenCalledWith(
expect.anything(),
@@ -45,7 +46,7 @@ describe('useInvitationCodes', () => {
test('clears the pending code and exposes a revoke error', async () => {
convex.revoke.mockRejectedValueOnce(new Error('Already revoked'));
- const { result } = renderHook(() => useInvitationCodes(true));
+ const { result } = await renderHook(() => useInvitationCodes(true));
await act(() => result.current.revoke('invite-1' as never));
expect(result.current).toMatchObject({ error: 'Already revoked', pendingId: null });
@@ -53,26 +54,26 @@ describe('useInvitationCodes', () => {
test('uses the revoke fallback for an opaque backend rejection', async () => {
convex.revoke.mockRejectedValueOnce(new Error('Server Error'));
- const { result } = renderHook(() => useInvitationCodes(true));
+ const { result } = await renderHook(() => useInvitationCodes(true));
await act(() => result.current.revoke('invite-1' as never));
expect(result.current.error).toBe('Unable to revoke invitation code');
});
- test('refreshes enabled codes every minute and clears the interval when disabled', () => {
+ test('refreshes enabled codes every minute and clears the interval when disabled', async () => {
vi.useFakeTimers();
const now = vi.spyOn(Date, 'now').mockReturnValue(100);
- const { rerender, unmount } = renderHook(({ enabled }) => useInvitationCodes(enabled), {
+ const { rerender, unmount } = await renderHook(({ enabled }) => useInvitationCodes(enabled), {
initialProps: { enabled: true }
});
now.mockReturnValue(200);
- act(() => {
+ await act(() => {
vi.advanceTimersByTime(60_000);
});
- rerender({ enabled: false });
- unmount();
+ await rerender({ enabled: false });
+ await unmount();
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), { now: 200 });
now.mockRestore();
diff --git a/apps/web/src/features/ideas/hooks/use-idea-clone-workspace.test.tsx b/apps/web/src/features/ideas/hooks/use-idea-clone-workspace.test.tsx
index ca9aa623..045d20d8 100644
--- a/apps/web/src/features/ideas/hooks/use-idea-clone-workspace.test.tsx
+++ b/apps/web/src/features/ideas/hooks/use-idea-clone-workspace.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useIdeaCloneWorkspace } from './use-idea-clone-workspace';
@@ -17,7 +17,10 @@ const deps = vi.hoisted(() => ({
useTripVersion: vi.fn()
}));
-vi.mock('@tanstack/react-router', () => ({ useNavigate: () => deps.navigate }));
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => deps.navigate
+}));
vi.mock('./use-idea-context', () => ({ useIdeaContext: () => deps.useIdeaContext() }));
vi.mock('@/features/trips/hooks/use-proposal-action-runner', () => ({
useProposalActionRunner: () => deps.useProposalActionRunner()
@@ -75,16 +78,16 @@ beforeEach(() => {
deps.useProposalActionRunner.mockReturnValue({ pendingAction: null, run: deps.run });
});
-function renderWorkspace(view: 'compare' | 'itinerary' | 'overview' = 'compare') {
+async function renderWorkspace(view: 'compare' | 'itinerary' | 'overview' = 'compare') {
return renderHook(() => useIdeaCloneWorkspace({ proposalId, sharedTripId, view, workingTripId }));
}
-test('submits a viewer draft and registers the compare screen as ideas context', () => {
- const { result } = renderWorkspace();
+test('submits a viewer draft and registers the compare screen as ideas context', async () => {
+ const { result } = await renderWorkspace();
expect(result.current.primary).toMatchObject({ intent: 'submit' });
expect(deps.useTripAgentContext).toHaveBeenCalledWith(expect.anything(), 'ideas');
- act(() => {
+ await act(() => {
result.current.runPrimary();
});
expect(deps.run).toHaveBeenCalledWith(
@@ -93,9 +96,9 @@ test('submits a viewer draft and registers the compare screen as ideas context',
);
});
-test('preserves route references when changing clone views and allows detail editing', () => {
- const { result } = renderWorkspace('overview');
- act(() => {
+test('preserves route references when changing clone views and allows detail editing', async () => {
+ const { result } = await renderWorkspace('overview');
+ await act(() => {
result.current.editDetails();
result.current.openView('itinerary', true);
});
@@ -113,23 +116,23 @@ test('preserves route references when changing clone views and allows detail edi
expect(navigation.params({})).toEqual({ proposalId, tripId: sharedTripId, view: 'itinerary' });
});
-test('marks a missing working trip as unavailable and keeps the page loading', () => {
+test('marks a missing working trip as unavailable and keeps the page loading', async () => {
deps.useTrip.mockReturnValue({ exists: false, trip: undefined });
deps.useReadyValue.mockReturnValue({ isLoading: true, value: undefined });
- const { result } = renderWorkspace('itinerary');
+ const { result } = await renderWorkspace('itinerary');
expect(result.current).toMatchObject({ missing: true, showLoading: true, trip: undefined });
expect(result.current.planningSection).toBe('itinerary');
});
test('opens an additional destination directly when no return travel exists', async () => {
- const { result } = renderWorkspace('overview');
+ const { result } = await renderWorkspace('overview');
await act(async () => {
result.current.navigation.openAddDestination();
await Promise.resolve();
});
- act(() => {
+ await act(() => {
result.current.navigation.openSection('itinerary');
});
@@ -142,15 +145,15 @@ test('opens an additional destination directly when no return travel exists', as
expect(navigations[1]?.params({})).toMatchObject({ view: 'itinerary' });
});
-test('keeps loading while a present trip has not resolved', () => {
+test('keeps loading while a present trip has not resolved', async () => {
deps.useReadyValue.mockReturnValue({ isLoading: false, value: undefined });
- const { result } = renderWorkspace();
+ const { result } = await renderWorkspace();
expect(result.current).toMatchObject({ missing: false, showLoading: true, trip: undefined });
});
-test('does not open editing or run a primary action when neither is allowed', () => {
+test('does not open editing or run a primary action when neither is allowed', async () => {
deps.useTrip.mockReturnValue({
exists: true,
trip: { departureTransfer: null, id: workingTripId, permissions: { canEdit: false } }
@@ -161,9 +164,9 @@ test('does not open editing or run a primary action when neither is allowed', ()
});
deps.useIdeaContext.mockReturnValue({ proposal: undefined, sharedTrip: undefined });
deps.useTripVersion.mockReturnValue({ approve: vi.fn(), proposal: undefined, submit: vi.fn() });
- const { result } = renderWorkspace('overview');
+ const { result } = await renderWorkspace('overview');
- act(() => {
+ await act(() => {
result.current.editDetails();
result.current.runPrimary();
});
@@ -188,7 +191,7 @@ test('confirms before replacing return travel and handles close and shared navig
}
});
deps.confirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
- const { result } = renderWorkspace('itinerary');
+ const { result } = await renderWorkspace('itinerary');
await act(async () => {
result.current.navigation.openAddDestination();
@@ -200,7 +203,7 @@ test('confirms before replacing return travel and handles close and shared navig
result.current.navigation.openAddDestination();
await Promise.resolve();
});
- act(() => {
+ await act(() => {
result.current.navigation.closeAddDestination();
result.current.openShared();
});
@@ -219,7 +222,7 @@ test('confirms before replacing return travel and handles close and shared navig
);
});
-test('runs approval and opens apply confirmation for review proposals', () => {
+test('runs approval and opens apply confirmation for review proposals', async () => {
const approve = vi.fn();
deps.useTripVersion.mockReturnValue({
approve,
@@ -232,9 +235,9 @@ test('runs approval and opens apply confirmation for review proposals', () => {
},
submit: vi.fn()
});
- const { result, rerender } = renderWorkspace();
+ const { result, rerender } = await renderWorkspace();
- act(() => {
+ await act(() => {
result.current.runPrimary();
});
expect(deps.run).toHaveBeenCalledWith('approve', expect.any(Function));
@@ -250,8 +253,8 @@ test('runs approval and opens apply confirmation for review proposals', () => {
},
submit: vi.fn()
});
- rerender();
- act(() => {
+ await rerender();
+ await act(() => {
result.current.runPrimary();
});
diff --git a/apps/web/src/features/ideas/hooks/use-idea-context.test.tsx b/apps/web/src/features/ideas/hooks/use-idea-context.test.tsx
index 2936f753..be6fc98a 100644
--- a/apps/web/src/features/ideas/hooks/use-idea-context.test.tsx
+++ b/apps/web/src/features/ideas/hooks/use-idea-context.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IdeaContextProvider, useIdeaContext, useOptionalIdeaContext } from './use-idea-context';
const deps = vi.hoisted(() => ({
@@ -49,10 +50,8 @@ beforeEach(() => {
deps.useTrip.mockReturnValue({ trip: { id: 'working-trip', name: 'My Portugal' } });
});
-afterEach(cleanup);
-
-test('provides shared, working, and viewer-author idea state', () => {
- render(
+test('provides shared, working, and viewer-author idea state', async () => {
+ await render(
{
);
- const context = screen.getByRole('status', { name: 'Idea context' }).textContent;
+ const context = page.getByRole('status', { name: 'Idea context' }).element().textContent;
expect(context).toContain('"isViewerAuthor":true');
expect(context).toContain('"view":"compare"');
expect(context).toContain('"working-trip"');
expect(deps.useTrip).toHaveBeenCalledWith('working-trip');
});
-test('keeps the context usable while the proposal has no working trip', () => {
+test('keeps the context usable while the proposal has no working trip', async () => {
deps.useTripVersion.mockReturnValue({ proposal: undefined });
- render(
+ await render(
{
);
- const context = screen.getByRole('status', { name: 'Idea context' }).textContent;
+ const context = page.getByRole('status', { name: 'Idea context' }).element().textContent;
expect(context).toContain('"isViewerAuthor":false');
expect(context).not.toContain('"workingTrip"');
expect(deps.useTrip).not.toHaveBeenCalled();
});
-test('returns null from the optional hook when no provider is present', () => {
- render();
+test('returns null from the optional hook when no provider is present', async () => {
+ await render();
- expect(screen.getByRole('status', { name: 'Optional idea context' }).textContent).toBe('null');
+ await expect
+ .element(page.getByRole('status', { name: 'Optional idea context' }))
+ .toHaveTextContent('null');
});
diff --git a/apps/web/src/features/ideas/hooks/use-idea-list-filters.test.tsx b/apps/web/src/features/ideas/hooks/use-idea-list-filters.test.tsx
index 7404ad33..eb7b3096 100644
--- a/apps/web/src/features/ideas/hooks/use-idea-list-filters.test.tsx
+++ b/apps/web/src/features/ideas/hooks/use-idea-list-filters.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import { useIdeaListFilters } from './use-idea-list-filters';
import type { WorkspaceIdea } from './use-workspace-ideas';
@@ -20,12 +20,12 @@ function idea(
}
describe('useIdeaListFilters', () => {
- test('defaults to pending and filters drafts in', () => {
+ test('defaults to pending and filters drafts in', async () => {
const proposals = [
idea({ id: 'p1' as Id<'tripProposals'>, status: 'draft', title: 'Coast' }),
idea({ id: 'p2' as Id<'tripProposals'>, status: 'merged', title: 'Done' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useIdeaListFilters({
emptyFilterMessage: (filter) => `empty:${filter}`,
proposals,
@@ -38,46 +38,46 @@ describe('useIdeaListFilters', () => {
expect(result.current.empty).toBe('empty:pending');
});
- test('clearing status resets to the default filter', () => {
- const { result } = renderHook(() =>
+ test('clearing status resets to the default filter', async () => {
+ const { result } = await renderHook(() =>
useIdeaListFilters({
emptyFilterMessage: () => 'none',
proposals: []
})
);
- act(() => {
+ await act(() => {
result.current.setStatusFilter('all');
});
expect(result.current.hasExtraFilters).toBe(true);
- act(() => {
+ await act(() => {
result.current.clearStatusFilter();
});
expect(result.current.statusFilter).toBe('pending');
expect(result.current.hasExtraFilters).toBe(false);
});
- test('filters by search query in the visible list', () => {
+ test('filters by search query in the visible list', async () => {
const proposals = [
idea({ id: 'p1' as Id<'tripProposals'>, status: 'draft', title: 'Coast day' }),
idea({ id: 'p2' as Id<'tripProposals'>, status: 'draft', title: 'Museum morning' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useIdeaListFilters({
emptyFilterMessage: () => 'none',
proposals
})
);
- act(() => {
+ await act(() => {
result.current.setQuery('museum');
});
expect(result.current.visibleProposals.map((item) => item.id)).toEqual(['p2']);
});
- test('accepts an explicit all filter when proposals have not loaded', () => {
- const { result } = renderHook(() =>
+ test('accepts an explicit all filter when proposals have not loaded', async () => {
+ const { result } = await renderHook(() =>
useIdeaListFilters({
defaultStatus: 'all',
emptyFilterMessage: () => 'none',
diff --git a/apps/web/src/features/ideas/hooks/use-start-idea-dialog.test.tsx b/apps/web/src/features/ideas/hooks/use-start-idea-dialog.test.tsx
index 7ed7d1ef..48c5fa95 100644
--- a/apps/web/src/features/ideas/hooks/use-start-idea-dialog.test.tsx
+++ b/apps/web/src/features/ideas/hooks/use-start-idea-dialog.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useStartIdeaDialog } from './use-start-idea-dialog';
@@ -12,7 +12,8 @@ vi.mock('./use-workspace-ideas', () => ({
useCreateIdea: () => deps.createIdea
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
useNavigate: () => deps.navigate
}));
@@ -22,19 +23,19 @@ beforeEach(() => {
});
describe('useStartIdeaDialog', () => {
- test('tracks dialog and trip selection booleans', () => {
- const { result } = renderHook(() => useStartIdeaDialog());
+ test('tracks dialog and trip selection booleans', async () => {
+ const { result } = await renderHook(() => useStartIdeaDialog());
expect(result.current.dialogOpen).toBe(false);
expect(result.current.isCreating).toBe(false);
- act(() => {
+ await act(() => {
result.current.openDialog();
result.current.setSelectedTripId('trip-1' as Id<'trips'>);
});
expect(result.current.dialogOpen).toBe(true);
expect(result.current.selectedTripId).toBe('trip-1');
- act(() => {
+ await act(() => {
result.current.onOpenChange(false);
});
expect(result.current.dialogOpen).toBe(false);
@@ -42,8 +43,8 @@ describe('useStartIdeaDialog', () => {
});
test('starts an idea and navigates on success', async () => {
- const { result } = renderHook(() => useStartIdeaDialog());
- act(() => {
+ const { result } = await renderHook(() => useStartIdeaDialog());
+ await act(() => {
result.current.setSelectedTripId('trip-1' as Id<'trips'>);
result.current.openDialog();
});
@@ -59,7 +60,7 @@ describe('useStartIdeaDialog', () => {
});
test('no-ops when no trip is selected', async () => {
- const { result } = renderHook(() => useStartIdeaDialog());
+ const { result } = await renderHook(() => useStartIdeaDialog());
await act(async () => {
await result.current.startIdea();
});
@@ -68,8 +69,8 @@ describe('useStartIdeaDialog', () => {
test('stops creating without navigation when the idea service returns no version', async () => {
deps.createIdea.mockResolvedValue(null);
- const { result } = renderHook(() => useStartIdeaDialog());
- act(() => result.current.setSelectedTripId('trip-1' as Id<'trips'>));
+ const { result } = await renderHook(() => useStartIdeaDialog());
+ await act(() => result.current.setSelectedTripId('trip-1' as Id<'trips'>));
await act(() => result.current.startIdea());
expect(result.current.isCreating).toBe(false);
expect(deps.navigate).not.toHaveBeenCalled();
diff --git a/apps/web/src/features/ideas/hooks/use-workspace-ideas.test.ts b/apps/web/src/features/ideas/hooks/use-workspace-ideas.test.ts
index cab750d4..5c07ffcf 100644
--- a/apps/web/src/features/ideas/hooks/use-workspace-ideas.test.ts
+++ b/apps/web/src/features/ideas/hooks/use-workspace-ideas.test.ts
@@ -1,6 +1,6 @@
import type { Id } from '@groam/backend/data-model';
import { toast } from '@groam/ui/components/toast';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useCreateIdea, useViewerOpenIdeas, useWorkspaceIdeas } from './use-workspace-ideas';
@@ -10,7 +10,8 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: (...args: unknown[]) => convex.useMutation(...args),
usePaginatedQuery: (...args: unknown[]) => convex.usePaginatedQuery(...args),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
@@ -29,8 +30,8 @@ beforeEach(() => {
convex.useQuery.mockReturnValue(undefined);
});
-test('uses the requested workspace idea page size and loading status', () => {
- const { result } = renderHook(() => useWorkspaceIdeas({ initialNumItems: 12 }));
+test('uses the requested workspace idea page size and loading status', async () => {
+ const { result } = await renderHook(() => useWorkspaceIdeas({ initialNumItems: 12 }));
expect(result.current).toMatchObject({ isLoading: true, proposals: [] });
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(
@@ -42,9 +43,9 @@ test('uses the requested workspace idea page size and loading status', () => {
);
});
-test('skips workspace and viewer idea queries when requested', () => {
- renderHook(() => useWorkspaceIdeas('skip'));
- renderHook(() => useViewerOpenIdeas('skip'));
+test('skips workspace and viewer idea queries when requested', async () => {
+ await renderHook(() => useWorkspaceIdeas('skip'));
+ await renderHook(() => useViewerOpenIdeas('skip'));
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', {
initialNumItems: 25
@@ -52,8 +53,8 @@ test('skips workspace and viewer idea queries when requested', () => {
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), 'skip');
});
-test('normalizes an unresolved viewer list to an empty loading result', () => {
- const { result } = renderHook(() => useViewerOpenIdeas());
+test('normalizes an unresolved viewer list to an empty loading result', async () => {
+ const { result } = await renderHook(() => useViewerOpenIdeas());
expect(result.current).toEqual({ isLoading: true, proposals: [] });
});
@@ -61,7 +62,7 @@ test('normalizes an unresolved viewer list to an empty loading result', () => {
test('creates an untitled idea and reports creation failures', async () => {
const create = vi.fn().mockRejectedValue(new Error('Trip is archived'));
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useCreateIdea());
+ const { result } = await renderHook(() => useCreateIdea());
let proposal: unknown;
await act(async () => {
diff --git a/apps/web/src/features/ideas/idea-clone/idea-clone-header-actions.test.tsx b/apps/web/src/features/ideas/idea-clone/idea-clone-header-actions.test.tsx
index ac3cac1b..fbea162e 100644
--- a/apps/web/src/features/ideas/idea-clone/idea-clone-header-actions.test.tsx
+++ b/apps/web/src/features/ideas/idea-clone/idea-clone-header-actions.test.tsx
@@ -1,26 +1,25 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IdeaCloneHeaderActions } from './idea-clone-header-actions';
const navigate = vi.fn();
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
useNavigate: () => navigate
}));
-vi.mock('@groam/ui/components/dropdown-menu', async () => {
- const { dropdownMenuTestMock } = await import('@/lib/test-mocks/dropdown-menu');
- return dropdownMenuTestMock;
-});
-
const proposalId = 'proposal-1' as Id<'tripProposals'>;
const sharedTripId = 'trip-1' as Id<'trips'>;
-function renderActions(overrides: Partial[0]> = {}) {
+async function renderActions(
+ overrides: Partial[0]> = {}
+) {
const onOpenShared = vi.fn();
const onPrimary = vi.fn();
- render(
+ await render(
{
- cleanup();
navigate.mockClear();
});
describe('IdeaCloneHeaderActions', () => {
- test('keeps the lifecycle action visible alongside editing and navigation', () => {
- const { onPrimary } = renderActions();
+ test('keeps the lifecycle action visible alongside editing and navigation', async () => {
+ const { onPrimary } = await renderActions();
- fireEvent.click(screen.getByTestId('idea-primary-action'));
+ await page.getByTestId('idea-primary-action').click();
expect(onPrimary).toHaveBeenCalled();
- expect(screen.getByTestId('trip-actions')).toBeTruthy();
+ await expect.element(page.getByTestId('trip-actions')).toBeInTheDocument();
});
- test('blocks the primary action while another one is running', () => {
- const { onPrimary } = renderActions({ pending: true });
+ test('blocks the primary action while another one is running', async () => {
+ const { onPrimary } = await renderActions({ pending: true });
- fireEvent.click(screen.getByTestId('idea-primary-action'));
+ await expect.element(page.getByTestId('idea-primary-action')).toBeDisabled();
+ // Forced: a disabled control must ignore clicks, which is the behavior under test.
+ await page.getByTestId('idea-primary-action').click({ force: true });
expect(onPrimary).not.toHaveBeenCalled();
});
- test('drops the primary button when the idea has no next step', () => {
- renderActions({ primary: null, status: 'closed' });
+ test('drops the primary button when the idea has no next step', async () => {
+ await renderActions({ primary: null, status: 'closed' });
- expect(screen.queryByTestId('idea-primary-action')).toBeNull();
- expect(screen.getByTestId('trip-actions')).toBeTruthy();
+ await expect.element(page.getByTestId('idea-primary-action')).not.toBeInTheDocument();
+ await expect.element(page.getByTestId('trip-actions')).toBeInTheDocument();
});
- test('keeps open-shared visible and open-changes in the overflow menu', () => {
- const { onOpenShared } = renderActions();
+ test('keeps open-shared visible and open-changes in the overflow menu', async () => {
+ const { onOpenShared } = await renderActions();
- fireEvent.click(screen.getByTestId('trip-actions'));
- fireEvent.click(screen.getByText('Open changes'));
+ await page.getByTestId('trip-actions').click();
+ await page.getByText('Open changes').click();
expect(navigate).toHaveBeenCalledWith(
expect.objectContaining({
params: { proposalId, tripId: sharedTripId, view: 'compare' }
})
);
- fireEvent.click(screen.getByTestId('idea-open-shared'));
+ await page.getByTestId('idea-open-shared').click();
expect(onOpenShared).toHaveBeenCalled();
});
- test('offers back-to-editing from compare instead of open-changes', () => {
- renderActions({ view: 'compare' });
+ test('offers back-to-editing from compare instead of open-changes', async () => {
+ await renderActions({ view: 'compare' });
- fireEvent.click(screen.getByTestId('trip-actions'));
- expect(screen.queryByText('Open changes')).toBeNull();
- fireEvent.click(screen.getByText('Back to editing'));
+ await page.getByTestId('trip-actions').click();
+ await expect.element(page.getByText('Open changes')).not.toBeInTheDocument();
+ await page.getByText('Back to editing').click();
expect(navigate).toHaveBeenCalledWith(
expect.objectContaining({
params: { proposalId, tripId: sharedTripId, view: 'itinerary' }
@@ -94,16 +94,16 @@ describe('IdeaCloneHeaderActions', () => {
});
});
-test('lets an editor edit details without opening a menu', () => {
+test('lets an editor edit details without opening a menu', async () => {
const onEdit = vi.fn();
- renderActions({ onEdit });
- fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
+ await renderActions({ onEdit });
+ await page.getByRole('button', { name: 'Edit details' }).click();
expect(onEdit).toHaveBeenCalledOnce();
- expect(screen.getByRole('button', { name: 'Open shared trip' })).toBeTruthy();
+ await expect.element(page.getByRole('button', { name: 'Open shared trip' })).toBeInTheDocument();
});
-test('does not offer editing to a read-only viewer', () => {
- renderActions({ canEdit: false });
- expect(screen.queryByRole('button', { name: 'Edit details' })).toBeNull();
- expect(screen.getByRole('button', { name: 'Open shared trip' })).toBeTruthy();
+test('does not offer editing to a read-only viewer', async () => {
+ await renderActions({ canEdit: false });
+ await expect.element(page.getByRole('button', { name: 'Edit details' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Open shared trip' })).toBeInTheDocument();
});
diff --git a/apps/web/src/features/ideas/idea-clone/idea-clone-nav.test.tsx b/apps/web/src/features/ideas/idea-clone/idea-clone-nav.test.tsx
index f17465bd..1a8cf42e 100644
--- a/apps/web/src/features/ideas/idea-clone/idea-clone-nav.test.tsx
+++ b/apps/web/src/features/ideas/idea-clone/idea-clone-nav.test.tsx
@@ -1,25 +1,26 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IdeaCloneNav } from './idea-clone-nav';
-afterEach(cleanup);
-
-test('keeps edit tabs apart from the review tab', () => {
+test('keeps edit tabs apart from the review tab', async () => {
const onOpen = vi.fn();
- render();
+ await render();
- expect(screen.getByRole('navigation', { name: 'Idea plan' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Overview' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Itinerary (2)' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Changes (3)' })).toBeTruthy();
+ await expect.element(page.getByRole('navigation', { name: 'Idea plan' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Overview' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Itinerary (2)' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Changes (3)' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Changes (3)' }));
+ await page.getByRole('button', { name: 'Changes (3)' }).click();
expect(onOpen).toHaveBeenCalledWith('compare');
});
-test.each(['overview', 'itinerary'] as const)('combines the read-only plan at %s', (view) => {
- render();
- expect(screen.getByTestId('trip-section-overview').textContent).toBe('Plan');
- expect(screen.queryByTestId('trip-section-itinerary')).toBeNull();
- expect(screen.getByTestId('idea-section-compare')).toBeTruthy();
+test.each(['overview', 'itinerary'] as const)('combines the read-only plan at %s', async (view) => {
+ await render(
+
+ );
+ await expect.element(page.getByTestId('trip-section-overview')).toHaveTextContent('Plan');
+ await expect.element(page.getByTestId('trip-section-itinerary')).not.toBeInTheDocument();
+ await expect.element(page.getByTestId('idea-section-compare')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/ideas/idea-clone/idea-clone-update-banner.test.tsx b/apps/web/src/features/ideas/idea-clone/idea-clone-update-banner.test.tsx
index 4e1ef8ed..9ea7bc89 100644
--- a/apps/web/src/features/ideas/idea-clone/idea-clone-update-banner.test.tsx
+++ b/apps/web/src/features/ideas/idea-clone/idea-clone-update-banner.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { needsSharedTripUpdate } from '@/features/ideas/hooks/idea-needs-update';
import type { ProposalDetail } from '@/types/trips';
import { IdeaCloneUpdateBanner } from './idea-clone-update-banner';
@@ -18,10 +19,8 @@ function proposal(overrides: Partial = {}): ProposalDetail {
} as ProposalDetail;
}
-afterEach(cleanup);
-
describe('needsSharedTripUpdate', () => {
- test('is true when the shared trip moved ahead or the idea is conflicted', () => {
+ test('is true when the shared trip moved ahead or the idea is conflicted', async () => {
expect(needsSharedTripUpdate(proposal({ sourceChanged: true }))).toBe(true);
expect(needsSharedTripUpdate(proposal({ status: 'conflicted' }))).toBe(true);
expect(needsSharedTripUpdate(proposal())).toBe(false);
@@ -29,21 +28,21 @@ describe('needsSharedTripUpdate', () => {
});
describe('IdeaCloneUpdateBanner', () => {
- test('stays hidden for a clean draft', () => {
- const { container } = render(
+ test('stays hidden for a clean draft', async () => {
+ const { container } = await render(
);
expect(container.firstChild).toBeNull();
});
- test('renders the update flow when the shared trip has changed', () => {
- render(
+ test('renders the update flow when the shared trip has changed', async () => {
+ await render(
);
- expect(screen.getByTestId('idea-update-flow')).toBeTruthy();
+ await expect.element(page.getByTestId('idea-update-flow')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/ideas/idea-clone/idea-copy-explainer.test.tsx b/apps/web/src/features/ideas/idea-clone/idea-copy-explainer.test.tsx
index b99ef148..4a651dbf 100644
--- a/apps/web/src/features/ideas/idea-clone/idea-copy-explainer.test.tsx
+++ b/apps/web/src/features/ideas/idea-clone/idea-copy-explainer.test.tsx
@@ -1,16 +1,15 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IdeaCopyExplainer } from './idea-copy-explainer';
-afterEach(cleanup);
+test('shows the shared trip and the private idea it creates', async () => {
+ await render();
-test('shows the shared trip and the private idea it creates', () => {
- render();
-
- expect(screen.getByLabelText('How an idea works')).toBeTruthy();
- expect(screen.getByText('Atlantic week')).toBeTruthy();
- expect(screen.getByText('Everyone sees this')).toBeTruthy();
- expect(screen.getByText('Your idea')).toBeTruthy();
- expect(screen.getByText('Edit here')).toBeTruthy();
- expect(screen.getByText('New idea')).toBeTruthy();
+ await expect.element(page.getByLabelText('How an idea works')).toBeInTheDocument();
+ await expect.element(page.getByText('Atlantic week')).toBeInTheDocument();
+ await expect.element(page.getByText('Everyone sees this')).toBeInTheDocument();
+ await expect.element(page.getByText('Your idea')).toBeInTheDocument();
+ await expect.element(page.getByText('Edit here')).toBeInTheDocument();
+ await expect.element(page.getByText('New idea')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/ideas/idea-clone/idea-workspace-heading.test.tsx b/apps/web/src/features/ideas/idea-clone/idea-workspace-heading.test.tsx
index 321a80cd..4192a49a 100644
--- a/apps/web/src/features/ideas/idea-clone/idea-workspace-heading.test.tsx
+++ b/apps/web/src/features/ideas/idea-clone/idea-workspace-heading.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import type { IdeaStatus } from '@/types/ideas';
import { IdeaWorkspaceHeading } from './idea-workspace-heading';
@@ -11,8 +12,6 @@ vi.mock('@/features/ideas/hooks/use-idea-context', () => ({
})
}));
-afterEach(cleanup);
-
test.each([
[true, false, 'draft', 'Editing idea'],
[false, false, 'draft', 'Viewing idea · Read-only'],
@@ -22,13 +21,13 @@ test.each([
[true, true, 'draft', 'Reviewing changes']
] as const)(
'labels permissions and review mode: %s %s %s',
- (canEdit, reviewing, status: IdeaStatus, label) => {
+ async (canEdit, reviewing, status: IdeaStatus, label) => {
const trip = {
id: 'working-trip',
name: 'Portugal week',
permissions: { canEdit }
} as TripDetail;
- render(
+ await render(
);
- expect(screen.getByText(label)).toBeTruthy();
- expect(screen.getByText('A coastal detour')).toBeTruthy();
- expect(screen.getByText('An idea for Portugal week')).toBeTruthy();
+ await expect.element(page.getByText(label)).toBeInTheDocument();
+ await expect.element(page.getByText('A coastal detour')).toBeInTheDocument();
+ await expect.element(page.getByText('An idea for Portugal week')).toBeInTheDocument();
}
);
diff --git a/apps/web/src/features/ideas/idea-detail/idea-detail-view.test.tsx b/apps/web/src/features/ideas/idea-detail/idea-detail-view.test.tsx
index 41e1ed0c..2d551e57 100644
--- a/apps/web/src/features/ideas/idea-detail/idea-detail-view.test.tsx
+++ b/apps/web/src/features/ideas/idea-detail/idea-detail-view.test.tsx
@@ -1,7 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IdeaDetailView } from './idea-detail-view';
const proposalId = 'proposal-1' as Id<'tripProposals'>;
@@ -15,13 +16,15 @@ vi.mock('@groam/ai/ui/context/agent-context', () => ({
useSetAgentContext: () => undefined
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
Navigate: ({ to }: { to: string }) => navigate:{to}
,
useNavigate: () => vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useQuery: () => state.trip
}));
@@ -41,22 +44,20 @@ beforeEach(() => {
state.trip = null;
});
-afterEach(cleanup);
-
describe('IdeaDetailView', () => {
- test('shows a loading state while the idea is unresolved', () => {
- render();
- expect(screen.getByRole('status', { name: 'Loading idea…' })).toBeTruthy();
+ test('shows a loading state while the idea is unresolved', async () => {
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Loading idea…' })).toBeInTheDocument();
});
- test('shows a not-found state when the idea is missing', () => {
+ test('shows a not-found state when the idea is missing', async () => {
state.proposal = null;
- render();
- expect(screen.getByText('Idea not found')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Back to ideas' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Idea not found')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Back to ideas' })).toBeInTheDocument();
});
- test('redirects a found idea to the nested clone', () => {
+ test('redirects a found idea to the nested clone', async () => {
state.proposal = {
id: proposalId,
ideaName: 'coast-day',
@@ -65,8 +66,10 @@ describe('IdeaDetailView', () => {
title: 'Add a coast day'
};
state.trip = { id: 'trip-1', name: 'Atlantic week' };
- render();
+ await render();
- expect(screen.getByText('navigate:/trips/$tripId/ideas/$proposalId/$view')).toBeTruthy();
+ await expect
+ .element(page.getByText('navigate:/trips/$tripId/ideas/$proposalId/$view'))
+ .toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/ideas/idea-list/idea-list-view.test.tsx b/apps/web/src/features/ideas/idea-list/idea-list-view.test.tsx
index ea704d39..ea015f66 100644
--- a/apps/web/src/features/ideas/idea-list/idea-list-view.test.tsx
+++ b/apps/web/src/features/ideas/idea-list/idea-list-view.test.tsx
@@ -1,6 +1,7 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { WorkspaceIdea } from '@/features/ideas/hooks/use-workspace-ideas';
import { IdeaListView } from './idea-list-view';
@@ -43,7 +44,8 @@ vi.mock('@/features/ideas/hooks/use-workspace-ideas', () => ({
})
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useNavigate: () => vi.fn()
}));
@@ -56,27 +58,27 @@ beforeEach(() => {
state.status = 'Exhausted';
});
-afterEach(cleanup);
-
describe('IdeaListView', () => {
- test('shows a labeled loading state while ideas load', () => {
+ test('shows a labeled loading state while ideas load', async () => {
state.isLoading = true;
- render();
- expect(screen.getByRole('status', { name: 'Loading ideas…' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Loading ideas…' })).toBeInTheDocument();
});
- test('invites the group to start an idea when none exist', () => {
- render();
- expect(screen.getByTestId('empty-screen')).toBeTruthy();
- expect(screen.getByText('Start an idea')).toBeTruthy();
- expect(screen.queryByText('Acme Labs')).toBeNull();
- expect(screen.getByTestId('ideas-title')).toBeTruthy();
- expect(screen.getAllByText(/idea until the group reviews/u).length).toBeGreaterThan(0);
- expect(screen.getAllByRole('button', { name: 'New idea' })).toHaveLength(1);
- expect(screen.queryByRole('button', { name: 'Delete draft' })).toBeNull();
+ test('invites the group to start an idea when none exist', async () => {
+ await render();
+ await expect.element(page.getByTestId('empty-screen')).toBeInTheDocument();
+ await expect.element(page.getByText('Start an idea')).toBeInTheDocument();
+ await expect.element(page.getByText('Acme Labs')).not.toBeInTheDocument();
+ await expect.element(page.getByTestId('ideas-title')).toBeInTheDocument();
+ expect(page.getByText(/idea until the group reviews/u).elements().length).toBeGreaterThan(0);
+ expect(page.getByRole('button', { name: 'New idea' }).elements()).toHaveLength(1);
+ await expect
+ .element(page.getByRole('button', { name: 'Delete draft' }))
+ .not.toBeInTheDocument();
});
- test('renders workspace ideas with their trip names', () => {
+ test('renders workspace ideas with their trip names', async () => {
state.proposals = [
{
author: { name: 'Alex Morgan', userId: 'user-alex' },
@@ -91,21 +93,25 @@ describe('IdeaListView', () => {
workingTripId: 'working-1' as WorkspaceIdea['workingTripId']
}
];
- render();
- expect(screen.queryByText('Acme Labs')).toBeNull();
- expect(screen.getByTestId('ideas-title')).toBeTruthy();
- expect(screen.getByText('Add a coast day')).toBeTruthy();
- expect(screen.getAllByText('Atlantic week')).toHaveLength(2);
- expect(screen.getByText('In review')).toBeTruthy();
- expect(screen.queryByText('Coast day')).toBeNull();
- expect(screen.getByRole('link', { name: 'Open' }).getAttribute('href')).toBe(
- '/trips/$tripId/ideas/$proposalId/$view'
- );
- expect(screen.queryByRole('button', { name: 'Delete draft' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Close without applying' })).toBeNull();
+ await render();
+ await expect.element(page.getByText('Acme Labs')).not.toBeInTheDocument();
+ await expect.element(page.getByTestId('ideas-title')).toBeInTheDocument();
+ await expect.element(page.getByText('Add a coast day')).toBeInTheDocument();
+ expect(page.getByText('Atlantic week').elements()).toHaveLength(2);
+ await expect.element(page.getByText('In review')).toBeInTheDocument();
+ await expect.element(page.getByText('Coast day')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Open' }))
+ .toHaveAttribute('href', '/trips/$tripId/ideas/$proposalId/$view');
+ await expect
+ .element(page.getByRole('button', { name: 'Delete draft' }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Close without applying' }))
+ .not.toBeInTheDocument();
});
- test('pins the viewer’s draft to the top of the workspace list', () => {
+ test('pins the viewer’s draft to the top of the workspace list', async () => {
state.proposals = [
{
author: { name: 'Alex Morgan', userId: 'user-alex' },
@@ -132,14 +138,19 @@ describe('IdeaListView', () => {
workingTripId: 'working-1' as WorkspaceIdea['workingTripId']
}
];
- render();
- expect(screen.queryByLabelText('Your pending idea')).toBeNull();
- expect(screen.getByText('Your draft')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Continue editing' })).toBeTruthy();
- expect(screen.getByTestId('idea-continue')).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Delete draft' })).toBeNull();
- const titles = screen
- .getAllByRole('heading', { level: 4 })
+ await render();
+ await expect.element(page.getByLabelText('Your pending idea')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Your draft')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Continue editing' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByTestId('idea-continue')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Delete draft' }))
+ .not.toBeInTheDocument();
+ const titles = page
+ .getByRole('heading', { level: 4 })
+ .elements()
.map((heading) => heading.textContent);
expect(titles[0]).toBe('Extend the stay');
expect(document.querySelector('[data-viewer-draft="true"]')?.className).toContain(
diff --git a/apps/web/src/features/ideas/idea-list/idea-list.test.tsx b/apps/web/src/features/ideas/idea-list/idea-list.test.tsx
index ab69a2d6..30e2ada1 100644
--- a/apps/web/src/features/ideas/idea-list/idea-list.test.tsx
+++ b/apps/web/src/features/ideas/idea-list/idea-list.test.tsx
@@ -1,21 +1,25 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
import type { ReactNode } from 'react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { WorkspaceIdea } from '@/features/ideas/hooks/use-workspace-ideas';
+import { expectPhoneListRow, expectWideListRow } from '@/testing/phone-list';
import { IdeaList } from './idea-list';
-vi.mock('@tanstack/react-router', () => ({
+const navigate = vi.hoisted(() => vi.fn());
+
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, className, to }: { children?: ReactNode; className?: string; to: string }) => (
{children}
),
- useNavigate: () => vi.fn()
+ useNavigate: () => navigate
}));
-afterEach(cleanup);
-
const proposals: WorkspaceIdea[] = [
{
author: { name: 'Alex Morgan', userId: 'user-alex' },
@@ -43,7 +47,7 @@ const proposals: WorkspaceIdea[] = [
}
];
-function renderList() {
+async function renderList() {
return render(
@@ -60,17 +64,16 @@ function renderList() {
);
}
-test('renders a compact search-and-status toolbar instead of stacked filter chrome', () => {
- renderList();
+test('renders a compact search-and-status toolbar instead of stacked filter chrome', async () => {
+ await renderList();
- const toolbar = document.querySelector('[aria-label="Filter ideas"]');
- expect(toolbar?.className).toContain('flex');
- expect(toolbar?.className).toContain('items-center');
- expect(screen.getByText('Add a coast day')).toBeTruthy();
- expect(screen.queryByRole('button', { name: /Open \d+/u })).toBeNull();
- expect(screen.queryByRole('button', { name: /Settled \d+/u })).toBeNull();
- expect(screen.getByLabelText('Search ideas')).toBeTruthy();
- expect(toolbar?.className).not.toContain('dashboard-panel');
+ const toolbar = page.getByLabelText('Filter ideas');
+ await expect.element(toolbar).toHaveClass('flex', 'items-center');
+ await expect.element(page.getByText('Add a coast day')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Open \d+/u })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Settled \d+/u })).not.toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search ideas')).toBeInTheDocument();
+ await expect.element(toolbar).not.toHaveClass('dashboard-panel');
const chrome = document.querySelector('[data-slot="data-table"]');
const table = document.querySelector('[data-slot="table"]');
const tableContainer = document.querySelector('[data-slot="table-container"]');
@@ -80,37 +83,40 @@ test('renders a compact search-and-status toolbar instead of stacked filter chro
expect(table?.className).toContain('w-full');
expect(table?.className).not.toContain('w-max');
expect(table?.className).toContain('table-fixed');
- expect(screen.getByLabelText('Filter ideas by status').textContent).toContain('Open');
- expect(screen.getByText('In review')).toBeTruthy();
- expect(screen.getByText('Add a museum morning')).toBeTruthy();
- expect(screen.queryByLabelText('Your pending idea')).toBeNull();
- expect(screen.getByText('Your draft')).toBeTruthy();
- expect(screen.queryByText('Coast day')).toBeNull();
- expect(screen.queryByText('Museum stop')).toBeNull();
- expect(screen.getAllByRole('button', { name: 'Continue editing' })).toHaveLength(1);
- expect(screen.getByTestId('idea-continue')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Open' })).toBeTruthy();
- const titles = screen.getAllByRole('heading', { level: 4 }).map((heading) => heading.textContent);
+ expect(page.getByLabelText('Filter ideas by status').element().textContent).toContain('Open');
+ await expect.element(page.getByText('In review')).toBeInTheDocument();
+ await expect.element(page.getByText('Add a museum morning')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Your pending idea')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Your draft')).toBeInTheDocument();
+ await expect.element(page.getByText('Coast day')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Museum stop')).not.toBeInTheDocument();
+ expect(page.getByRole('button', { name: 'Continue editing' }).elements()).toHaveLength(1);
+ await expect.element(page.getByTestId('idea-continue')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Open' })).toBeInTheDocument();
+ const titles = page
+ .getByRole('heading', { level: 4 })
+ .elements()
+ .map((heading) => heading.textContent);
expect(titles[0]).toBe('Add a museum morning');
expect(titles[1]).toBe('Add a coast day');
expect(document.querySelector('[data-viewer-draft="true"]')?.className).toContain('bg-primary/5');
});
-test('searches visible idea fields and can clear the filter', () => {
- renderList();
+test('searches visible idea fields and can clear the filter', async () => {
+ await renderList();
- fireEvent.change(screen.getByLabelText('Search ideas'), { target: { value: 'museum' } });
- expect(screen.queryByText('Add a coast day')).toBeNull();
- expect(screen.getByText('Add a museum morning')).toBeTruthy();
+ await page.getByLabelText('Search ideas').fill('museum');
+ await expect.element(page.getByText('Add a coast day')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Add a museum morning')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Clear idea filters' }));
- expect(screen.getByText('Add a coast day')).toBeTruthy();
- expect((screen.getByLabelText('Search ideas') as HTMLInputElement).value).toBe('');
+ await page.getByRole('button', { name: 'Clear idea filters' }).click();
+ await expect.element(page.getByText('Add a coast day')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search ideas')).toHaveValue('');
});
-test('supports custom row rendering and external activation', () => {
+test('supports custom row rendering and external activation', async () => {
const onRowActivate = vi.fn();
- render(
+ await render(
'none'}
onRowActivate={onRowActivate}
@@ -120,7 +126,33 @@ test('supports custom row rendering and external activation', () => {
/>
);
- expect(document.querySelector('table')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Add a coast day' })).toBeTruthy();
- expect(screen.queryByRole('link', { name: 'View' })).toBeNull();
+ await expect.element(page.getByRole('table')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Add a coast day' })).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'View' })).not.toBeInTheDocument();
+});
+
+describe('on a phone', () => {
+ const firstRow = () =>
+ page
+ .getByRole('row')
+ .filter({ has: page.getByRole('heading', { level: 4 }) })
+ .first();
+
+ test('keeps titles readable, drops secondary columns and opens a row on tap', async () => {
+ await useViewport(390, 844);
+ await renderList();
+ await expectPhoneListRow(firstRow());
+ // The trip name moves under the title.
+ await expect.element(firstRow().getByText('City break').first()).toBeVisible();
+ await expect.element(page.getByText('Started by')).not.toBeVisible();
+
+ await firstRow().getByRole('heading', { level: 4 }).click();
+ expect(navigate).toHaveBeenCalledOnce();
+ });
+
+ test('shows the trip and author columns again on a wide screen', async () => {
+ await renderList();
+ await expectWideListRow(firstRow());
+ await expect.element(page.getByText('Started by')).toBeVisible();
+ });
});
diff --git a/apps/web/src/features/ideas/ideas-sidebar-nav.test.tsx b/apps/web/src/features/ideas/ideas-sidebar-nav.test.tsx
index a94e4d09..389454f7 100644
--- a/apps/web/src/features/ideas/ideas-sidebar-nav.test.tsx
+++ b/apps/web/src/features/ideas/ideas-sidebar-nav.test.tsx
@@ -1,10 +1,11 @@
import { SidebarProvider } from '@groam/ui/components/sidebar';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { WorkspaceIdea } from '@/features/ideas/hooks/use-workspace-ideas';
import { resetSidebarChromeStore, useSidebarChromeStore } from '@/lib/stores/sidebar-chrome-store';
-import { stubMatchMedia } from '@/testing/stub-match-media';
+import { testIds } from '@/lib/test-ids';
import { IdeasSidebarNav } from './ideas-sidebar-nav';
const state = vi.hoisted(() => ({
@@ -16,7 +17,8 @@ const state = vi.hoisted(() => ({
status: 'CanLoadMore' as 'CanLoadMore' | 'Done' | 'LoadingFirstPage' | 'LoadingMore'
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
{children}
@@ -60,7 +62,7 @@ function proposal(
};
}
-function renderNav() {
+async function renderNav() {
return render(
@@ -80,15 +82,9 @@ beforeEach(() => {
state.pathname = '/ideas';
state.proposals = [];
state.status = 'CanLoadMore';
- stubMatchMedia();
});
-afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
-});
-
-test('shows browse trips when there are no open ideas and nothing left to fetch', () => {
+test('shows browse trips when there are no open ideas and nothing left to fetch', async () => {
state.proposals = [
proposal({
id: 'closed' as WorkspaceIdea['id'],
@@ -98,12 +94,12 @@ test('shows browse trips when there are no open ideas and nothing left to fetch'
];
state.status = 'Done';
- renderNav();
- expect(screen.getByText('Browse trips')).toBeTruthy();
- expect(screen.queryByText('Load more')).toBeNull();
+ await renderNav();
+ await expect.element(page.getByText('Browse trips')).toBeInTheDocument();
+ await expect.element(page.getByText('Load more')).not.toBeInTheDocument();
});
-test('offers load more when the first fetched page only contains closed ideas', () => {
+test('offers load more when the first fetched page only contains closed ideas', async () => {
state.proposals = [
proposal({
id: 'closed' as WorkspaceIdea['id'],
@@ -113,15 +109,15 @@ test('offers load more when the first fetched page only contains closed ideas',
];
state.status = 'CanLoadMore';
- renderNav();
- expect(screen.queryByText('Browse trips')).toBeNull();
- expect(screen.getByText('Load more')).toBeTruthy();
+ await renderNav();
+ await expect.element(page.getByText('Browse trips')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Load more')).toBeInTheDocument();
- fireEvent.click(screen.getByText('Load more'));
+ await page.getByText('Load more').click();
expect(state.loadMore).toHaveBeenCalledWith(5);
});
-test('lists open ideas and highlights the active working draft route', () => {
+test('lists open ideas and highlights the active working draft route', async () => {
useSidebarChromeStore.getState().setSectionOpen('ideas', false);
state.proposals = [
proposal({
@@ -139,33 +135,30 @@ test('lists open ideas and highlights the active working draft route', () => {
state.pathname = '/trips/trip-working/overview';
state.status = 'Done';
- renderNav();
- fireEvent.click(screen.getByRole('button', { name: 'Expand ideas' }));
- expect(screen.getByText('Your draft: Extend Lisbon stay')).toBeTruthy();
- expect(screen.getByText('Share the coast day')).toBeTruthy();
- const labels = screen
- .getAllByRole('link')
+ await renderNav();
+ await page.getByRole('button', { name: 'Expand ideas' }).click();
+ await expect.element(page.getByText('Your draft: Extend Lisbon stay')).toBeInTheDocument();
+ await expect.element(page.getByText('Share the coast day')).toBeInTheDocument();
+ const labels = page
+ .getByRole('link')
+ .elements()
.map((link) => link.textContent)
.filter((text) => text === 'Your draft: Extend Lisbon stay' || text === 'Share the coast day');
expect(labels[0]).toBe('Your draft: Extend Lisbon stay');
- expect(
- screen.getByText('Your draft: Extend Lisbon stay').closest('a')?.getAttribute('data-active')
- ).toBe('true');
- expect(
- screen.getByText('Your draft: Extend Lisbon stay').closest('[data-viewer-draft="true"]')
- ?.className
- ).toContain('bg-primary/5');
- expect(screen.getByText('Ideas').closest('a')?.getAttribute('href')).toBe('/ideas');
- expect(screen.getByText('Ideas').closest('a')?.getAttribute('data-active')).toBe('false');
+ const draftLink = page.getByRole('link', { name: 'Your draft: Extend Lisbon stay' });
+ await expect.element(draftLink).toHaveAttribute('data-active', 'true');
+ await expect.element(draftLink).toHaveClass('bg-primary/5');
+ await expect.element(page.getByTestId(testIds.navIdeas)).toHaveAttribute('href', '/ideas');
+ await expect.element(page.getByTestId(testIds.navIdeas)).toHaveAttribute('data-active', 'false');
});
-test('highlights the ideas index on /ideas', () => {
+test('highlights the ideas index on /ideas', async () => {
state.status = 'Done';
- renderNav();
- expect(screen.getByText('Ideas').closest('a')?.getAttribute('data-active')).toBe('true');
+ await renderNav();
+ await expect.element(page.getByTestId(testIds.navIdeas)).toHaveAttribute('data-active', 'true');
});
-test('collapses listed ideas from the parent action without leaving the index link', () => {
+test('collapses listed ideas from the parent action without leaving the index link', async () => {
state.proposals = [
proposal({
id: 'draft' as WorkspaceIdea['id'],
@@ -175,14 +168,14 @@ test('collapses listed ideas from the parent action without leaving the index li
];
state.status = 'Done';
- renderNav();
- expect(screen.getByText('Your draft: Extend Lisbon stay')).toBeTruthy();
- expect(screen.getByText('Ideas').closest('a')?.getAttribute('href')).toBe('/ideas');
+ await renderNav();
+ await expect.element(page.getByText('Your draft: Extend Lisbon stay')).toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.navIdeas)).toHaveAttribute('href', '/ideas');
- fireEvent.click(screen.getByText('Ideas'));
- expect(screen.getByText('Your draft: Extend Lisbon stay')).toBeTruthy();
+ await page.getByTestId(testIds.navIdeas).click();
+ await expect.element(page.getByText('Your draft: Extend Lisbon stay')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Collapse ideas' }));
- expect(screen.queryByText('Your draft: Extend Lisbon stay')).toBeNull();
- expect(screen.getByRole('button', { name: 'Expand ideas' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Collapse ideas' }).click();
+ await expect.element(page.getByText('Your draft: Extend Lisbon stay')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Expand ideas' })).toBeInTheDocument();
});
diff --git a/apps/web/src/features/inbox/hooks/use-workspace-notifications.test.ts b/apps/web/src/features/inbox/hooks/use-workspace-notifications.test.ts
index 076c60d1..78749b3f 100644
--- a/apps/web/src/features/inbox/hooks/use-workspace-notifications.test.ts
+++ b/apps/web/src/features/inbox/hooks/use-workspace-notifications.test.ts
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useWorkspaceNotifications } from './use-workspace-notifications';
@@ -8,7 +8,8 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: (...args: unknown[]) => convex.useMutation(...args),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -19,14 +20,14 @@ beforeEach(() => {
convex.useMutation.mockReturnValue(vi.fn());
});
-test('uses a safe empty list while notifications are loading', () => {
- const { result } = renderHook(() => useWorkspaceNotifications());
+test('uses a safe empty list while notifications are loading', async () => {
+ const { result } = await renderHook(() => useWorkspaceNotifications());
expect(result.current).toMatchObject({ isLoading: true, notifications: [], unread: 0 });
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), {});
});
-test('counts unread notifications and invokes both read mutations', () => {
+test('counts unread notifications and invokes both read mutations', async () => {
const markRead = vi.fn();
const markAllRead = vi.fn();
convex.useQuery.mockReturnValue([
@@ -34,10 +35,10 @@ test('counts unread notifications and invokes both read mutations', () => {
{ id: 'notification-2', readAt: 123 }
]);
convex.useMutation.mockReturnValueOnce(markRead).mockReturnValueOnce(markAllRead);
- const { result } = renderHook(() => useWorkspaceNotifications());
+ const { result } = await renderHook(() => useWorkspaceNotifications());
expect(result.current).toMatchObject({ isLoading: false, unread: 1 });
- act(() => {
+ await act(() => {
result.current.markNotificationRead('notification-1' as Id<'workspaceNotifications'>);
result.current.markAllRead();
});
diff --git a/apps/web/src/features/issues/hooks/use-issue-list-filters.test.tsx b/apps/web/src/features/issues/hooks/use-issue-list-filters.test.tsx
index 44933f72..4fabf4a7 100644
--- a/apps/web/src/features/issues/hooks/use-issue-list-filters.test.tsx
+++ b/apps/web/src/features/issues/hooks/use-issue-list-filters.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import type { IssueListItem } from './issue-list-item';
import { useIssueListFilters } from './use-issue-list-filters';
@@ -18,12 +18,12 @@ function issue(
}
describe('useIssueListFilters', () => {
- test('defaults to open issues', () => {
+ test('defaults to open issues', async () => {
const issues = [
issue({ id: 'i1' as Id<'tripIssues'>, status: 'open', title: 'Coast' }),
issue({ id: 'i2' as Id<'tripIssues'>, status: 'closed', title: 'Done' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useIssueListFilters({
emptyFilterMessage: (filter) => `empty:${filter}`,
issues
@@ -35,12 +35,12 @@ describe('useIssueListFilters', () => {
expect(result.current.empty).toBe('empty:open');
});
- test('honors an external default status', () => {
+ test('honors an external default status', async () => {
const issues = [
issue({ id: 'i1' as Id<'tripIssues'>, status: 'open', title: 'Coast' }),
issue({ id: 'i2' as Id<'tripIssues'>, status: 'closed', title: 'Done' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useIssueListFilters({
defaultStatus: 'closed',
emptyFilterMessage: () => 'none',
@@ -52,28 +52,28 @@ describe('useIssueListFilters', () => {
expect(result.current.hasExtraFilters).toBe(false);
});
- test('clearing status resets to the default filter', () => {
- const { result } = renderHook(() =>
+ test('clearing status resets to the default filter', async () => {
+ const { result } = await renderHook(() =>
useIssueListFilters({
emptyFilterMessage: () => 'none',
issues: []
})
);
- act(() => {
+ await act(() => {
result.current.setStatusFilter('closed');
});
expect(result.current.hasExtraFilters).toBe(true);
- act(() => {
+ await act(() => {
result.current.clearStatusFilter();
});
expect(result.current.statusFilter).toBe('open');
expect(result.current.hasExtraFilters).toBe(false);
});
- test('handles an unloaded issue list and all-status selection', () => {
- const { result } = renderHook(() =>
+ test('handles an unloaded issue list and all-status selection', async () => {
+ const { result } = await renderHook(() =>
useIssueListFilters({
defaultStatus: 'all',
emptyFilterMessage: () => 'none',
diff --git a/apps/web/src/features/issues/hooks/use-issues-agent-context.test.tsx b/apps/web/src/features/issues/hooks/use-issues-agent-context.test.tsx
index 089c1c7d..37fe6c07 100644
--- a/apps/web/src/features/issues/hooks/use-issues-agent-context.test.tsx
+++ b/apps/web/src/features/issues/hooks/use-issues-agent-context.test.tsx
@@ -1,7 +1,8 @@
import { AgentContextProvider, useCurrentAgentContext } from '@groam/ai/ui/context/agent-context';
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import {
issueDetailScreenAgentContext,
issueListScreenAgentContext,
@@ -36,9 +37,7 @@ function Probe() {
return ;
}
-afterEach(cleanup);
-
-test('builds explicit list and detail screen context', () => {
+test('builds explicit list and detail screen context', async () => {
expect(issueListScreenAgentContext('Acme Labs', [workspaceIssue])).toMatchObject({
capabilities: [],
data: {
@@ -70,24 +69,24 @@ test('builds explicit list and detail screen context', () => {
});
});
-test('forwards issue screen changes through the shared context', () => {
- const view = render(
+test('forwards issue screen changes through the shared context', async () => {
+ const view = await render(
);
- expect(screen.getByRole('status', { name: 'Issue agent context' }).textContent).toContain(
+ expect(page.getByRole('status', { name: 'Issue agent context' }).element().textContent).toContain(
'Acme Labs'
);
- view.rerender(
+ await view.rerender(
);
- expect(screen.getByRole('status', { name: 'Issue agent context' }).textContent).toContain(
+ expect(page.getByRole('status', { name: 'Issue agent context' }).element().textContent).toContain(
'Choose a coast day'
);
});
diff --git a/apps/web/src/features/issues/hooks/use-workspace-issues.test.tsx b/apps/web/src/features/issues/hooks/use-workspace-issues.test.tsx
index 0e3ad452..7c151dc2 100644
--- a/apps/web/src/features/issues/hooks/use-workspace-issues.test.tsx
+++ b/apps/web/src/features/issues/hooks/use-workspace-issues.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useCreateIssue, useWorkspaceIssues } from './use-workspace-issues';
@@ -9,7 +9,10 @@ const convex = vi.hoisted(() => ({
}));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -25,24 +28,24 @@ beforeEach(() => {
});
describe('useWorkspaceIssues', () => {
- test('exposes the first page of workspace issues', () => {
+ test('exposes the first page of workspace issues', async () => {
convex.usePaginatedQuery.mockReturnValue({
loadMore: vi.fn(),
results: [{ id: 'issue-1', title: 'Coast day' }],
status: 'CanLoadMore'
});
- const { result } = renderHook(() => useWorkspaceIssues());
+ const { result } = await renderHook(() => useWorkspaceIssues());
expect(result.current.issues).toEqual([{ id: 'issue-1', title: 'Coast day' }]);
expect(result.current.isLoading).toBe(false);
});
- test('skips pagination on request and reports first-page loading', () => {
+ test('skips pagination on request and reports first-page loading', async () => {
convex.usePaginatedQuery.mockReturnValue({
loadMore: vi.fn(),
results: [],
status: 'LoadingFirstPage'
});
- const { result } = renderHook(() => useWorkspaceIssues('skip'));
+ const { result } = await renderHook(() => useWorkspaceIssues('skip'));
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', {
initialNumItems: 25
});
@@ -54,7 +57,7 @@ describe('useCreateIssue', () => {
test('creates an issue with its trip context', async () => {
const create = vi.fn().mockResolvedValue('issue-1');
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useCreateIssue());
+ const { result } = await renderHook(() => useCreateIssue());
await expect(result.current(tripId, 'Coast day', 'Add a coast day.')).resolves.toBe('issue-1');
expect(create).toHaveBeenCalledWith({ body: 'Add a coast day.', title: 'Coast day', tripId });
});
@@ -62,7 +65,7 @@ describe('useCreateIssue', () => {
test('returns null and toasts when issue creation fails', async () => {
const create = vi.fn().mockRejectedValue(new Error('Trip is archived'));
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useCreateIssue());
+ const { result } = await renderHook(() => useCreateIssue());
let created: Id<'tripIssues'> | null = null;
await act(async () => {
diff --git a/apps/web/src/features/issues/issue-detail/issue-agent-banner.test.tsx b/apps/web/src/features/issues/issue-detail/issue-agent-banner.test.tsx
index 03478f8b..e83bb177 100644
--- a/apps/web/src/features/issues/issue-detail/issue-agent-banner.test.tsx
+++ b/apps/web/src/features/issues/issue-detail/issue-agent-banner.test.tsx
@@ -1,7 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IssueAgentBanner } from './issue-agent-banner';
const retry = vi.hoisted(() => vi.fn());
@@ -18,7 +19,8 @@ const state = vi.hoisted(() => ({
}
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}
}));
@@ -37,14 +39,12 @@ beforeEach(() => {
state.run = null;
});
-afterEach(cleanup);
-
-test('hides when the Issue agent has no run', () => {
- render();
- expect(screen.queryByTestId('issue-agent-banner')).toBeNull();
+test('hides when the Issue agent has no run', async () => {
+ await render();
+ await expect.element(page.getByTestId('issue-agent-banner')).not.toBeInTheDocument();
});
-test('renders the shared agent run banner for an issue run', () => {
+test('renders the shared agent run banner for an issue run', async () => {
state.run = {
error: null,
headline: 'Started working',
@@ -53,13 +53,15 @@ test('renders the shared agent run banner for an issue run', () => {
status: 'running',
surface: 'standalone'
};
- render();
- expect(screen.getByTestId('issue-agent-banner')).toBeTruthy();
- expect(screen.getByText('Issue agent')).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Retry from zero' })).toBeNull();
+ await render();
+ await expect.element(page.getByTestId('issue-agent-banner')).toBeInTheDocument();
+ await expect.element(page.getByText('Issue agent')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Retry from zero' }))
+ .not.toBeInTheDocument();
});
-test('retries from zero when the Issue agent run failed', () => {
+test('retries from zero when the Issue agent run failed', async () => {
state.run = {
error: 'The model timed out',
headline: 'Run failed',
@@ -68,7 +70,7 @@ test('retries from zero when the Issue agent run failed', () => {
status: 'failed',
surface: 'standalone'
};
- render();
- fireEvent.click(screen.getByRole('button', { name: 'Retry from zero' }));
+ await render();
+ await page.getByRole('button', { name: 'Retry from zero' }).click();
expect(retry).toHaveBeenCalledWith({ runId: 'run-1' });
});
diff --git a/apps/web/src/features/issues/issue-detail/issue-detail-view.test.tsx b/apps/web/src/features/issues/issue-detail/issue-detail-view.test.tsx
index e25b0f83..0e2ec0d7 100644
--- a/apps/web/src/features/issues/issue-detail/issue-detail-view.test.tsx
+++ b/apps/web/src/features/issues/issue-detail/issue-detail-view.test.tsx
@@ -1,7 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { IssueDetailView } from './issue-detail-view';
const state = vi.hoisted(() => ({
@@ -43,7 +44,8 @@ vi.mock('@groam/ai/ui/context/agent-context', () => ({
useSetAgentContext: () => undefined
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useNavigate: () => vi.fn()
}));
@@ -101,44 +103,42 @@ beforeEach(() => {
state.run = null;
});
-afterEach(cleanup);
-
describe('IssueDetailView', () => {
- test('shows a loading state while the issue is unresolved', () => {
- render();
- expect(screen.getByRole('status', { name: 'Loading issue…' })).toBeTruthy();
+ test('shows a loading state while the issue is unresolved', async () => {
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Loading issue…' })).toBeInTheDocument();
});
- test('shows a not-found state when the issue is missing', () => {
+ test('shows a not-found state when the issue is missing', async () => {
state.issue = null;
- render();
- expect(screen.getByText('Issue not found')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Back to issues' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Issue not found')).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: 'Back to issues' })).toBeInTheDocument();
});
- test('puts the back link above a title hero and status badge', () => {
+ test('puts the back link above a title hero and status badge', async () => {
state.issue = loadedIssue();
- render();
+ await render();
- const back = screen.getByRole('link', { name: 'All issues' });
- const title = screen.getByRole('heading', { name: /Coast day/u });
+ const back = page.getByRole('link', { name: 'All issues' }).element();
+ const title = page.getByRole('heading', { name: /Coast day/u }).element();
expect(back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(screen.getByText('Open')).toBeTruthy();
- expect(screen.getByText(/opened by/u).textContent).toMatch(/Alex Morgan/u);
- expect(screen.getByRole('link', { name: 'Atlantic week' })).toBeTruthy();
- expect(screen.queryByText(/Open · Atlantic week/u)).toBeNull();
+ await expect.element(page.getByText('Open')).toBeInTheDocument();
+ expect(page.getByText(/opened by/u).element().textContent).toMatch(/Alex Morgan/u);
+ await expect.element(page.getByRole('link', { name: 'Atlantic week' })).toBeInTheDocument();
+ await expect.element(page.getByText(/Open · Atlantic week/u)).not.toBeInTheDocument();
});
- test('renders the issue body as the opening timeline comment', () => {
+ test('renders the issue body as the opening timeline comment', async () => {
state.issue = loadedIssue();
- render();
- expect(screen.getByText('Add a coast day near Lisbon.')).toBeTruthy();
- expect(screen.getByText('Author')).toBeTruthy();
- expect(screen.queryByRole('heading', { name: 'Discussion' })).toBeNull();
+ await render();
+ await expect.element(page.getByText('Add a coast day near Lisbon.')).toBeInTheDocument();
+ await expect.element(page.getByText('Author')).toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Discussion' })).not.toBeInTheDocument();
expect(document.querySelector('[data-slot="timeline"]')).toBeTruthy();
});
- test('renders the issue agent banner when Groam is assigned', () => {
+ test('renders the issue agent banner when Groam is assigned', async () => {
state.issue = {
...loadedIssue(),
assignee: { agentId: 'issue', kind: 'agent' }
@@ -150,7 +150,7 @@ describe('IssueDetailView', () => {
issueId,
status: 'running'
};
- render();
- expect(screen.getByTestId('issue-agent-banner')).toBeTruthy();
+ await render();
+ await expect.element(page.getByTestId('issue-agent-banner')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/issues/issue-detail/issue-detail.test.tsx b/apps/web/src/features/issues/issue-detail/issue-detail.test.tsx
index 194a2eaf..ad7d7255 100644
--- a/apps/web/src/features/issues/issue-detail/issue-detail.test.tsx
+++ b/apps/web/src/features/issues/issue-detail/issue-detail.test.tsx
@@ -1,8 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { stubPopoverEnvironment } from '@groam/ui/lib/stub-popover-environment';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import {
WorkspaceContext,
type WorkspaceContextValue
@@ -26,7 +26,8 @@ const state = vi.hoisted(() => ({
issue: null as null | Record
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useNavigate: () => vi.fn()
}));
@@ -65,7 +66,6 @@ function issue(overrides: Record = {}) {
beforeEach(() => {
vi.clearAllMocks();
- stubPopoverEnvironment();
actions.addComment.mockResolvedValue(true);
actions.assignIssueAgent.mockResolvedValue(true);
actions.implement.mockResolvedValue(null);
@@ -74,9 +74,7 @@ beforeEach(() => {
state.issue = issue();
});
-afterEach(cleanup);
-
-function renderIssue(ui: ReactNode, viewerName = 'Lea Demo') {
+async function renderIssue(ui: ReactNode, viewerName = 'Lea Demo') {
return render(
{
- test('keeps metadata in the sidebar and close next to comment', () => {
- renderIssue();
+ test('keeps metadata in the sidebar and close next to comment', async () => {
+ await renderIssue();
expect(composerBadge()?.textContent).toBe('LD');
- expect(screen.getByRole('heading', { name: 'Assignees' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Idea' })).toBeTruthy();
- expect(screen.getByText('No one')).toBeTruthy();
- expect(screen.getByText('None yet')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Edit assignees' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Edit idea' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Assign Issue agent' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'New idea' })).toBeNull();
- expect(screen.getByRole('button', { name: 'Close issue' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Comment' })).toBeTruthy();
- expect(screen.queryByRole('heading', { name: 'Discussion' })).toBeNull();
+ await expect.element(page.getByRole('heading', { name: 'Assignees' })).toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Idea' })).toBeInTheDocument();
+ await expect.element(page.getByText('No one')).toBeInTheDocument();
+ await expect.element(page.getByText('None yet')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Edit assignees' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Edit idea' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Assign Issue agent' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'New idea' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Close issue' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Comment' })).toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Discussion' })).not.toBeInTheDocument();
expect(document.querySelector('[data-slot="shell-two-columns"]')?.className).toContain(
'xl:grid-cols-[minmax(0,1fr)_20rem]'
);
expect(document.querySelector('[data-slot="shell-right-column"]')?.className).toContain(
'xl:sticky'
);
- expect(screen.getByRole('heading', { name: 'Decide by' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Decide by' })).toBeTruthy();
- expect(screen.getByText('Choose a date')).toBeTruthy();
+ await expect.element(page.getByRole('heading', { name: 'Decide by' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Decide by' })).toBeInTheDocument();
+ await expect.element(page.getByText('Choose a date')).toBeInTheDocument();
expect(document.querySelector('input[type="date"]')).toBeNull();
});
- test('sets a decide-by date from the calendar popover', () => {
+ test('sets a decide-by date from the calendar popover', async () => {
state.issue = issue({ dueAt: new Date(2026, 7, 23, 12).getTime() });
- renderIssue();
+ await renderIssue();
- fireEvent.click(screen.getByRole('button', { name: 'Decide by' }));
- const next = new Date(2026, 7, 24);
- const day = document.querySelector(
- `[data-slot="calendar"] [data-day="${next.toLocaleDateString()}"]`
- );
- expect(day).toBeTruthy();
- fireEvent.click(day as Element);
+ await page.getByRole('button', { name: 'Decide by' }).click();
+ await page.getByRole('button', { name: /August 24th, 2026/u }).click();
expect(actions.setDueAt).toHaveBeenCalledWith(new Date(2026, 7, 24, 12).getTime());
});
- test('clears an optional decide-by date', () => {
+ test('clears an optional decide-by date', async () => {
state.issue = issue({ dueAt: new Date(2026, 7, 23, 12).getTime() });
- renderIssue();
+ await renderIssue();
- fireEvent.click(screen.getByRole('button', { name: 'Decide by' }));
- fireEvent.click(screen.getByRole('button', { name: 'Clear date' }));
+ await page.getByRole('button', { name: 'Decide by' }).click();
+ await page.getByRole('button', { name: 'Clear date' }).click();
expect(actions.setDueAt).toHaveBeenCalledWith(null);
});
- test('assigns the Issue agent from the assignees gear popover', () => {
- renderIssue();
- fireEvent.click(screen.getByRole('button', { name: 'Edit assignees' }));
- fireEvent.click(screen.getByRole('button', { name: 'Assign Issue agent' }));
+ test('assigns the Issue agent from the assignees gear popover', async () => {
+ await renderIssue();
+ await page.getByRole('button', { name: 'Edit assignees' }).click();
+ await page.getByRole('button', { name: 'Assign Issue agent' }).click();
expect(actions.assignIssueAgent).toHaveBeenCalledOnce();
});
- test('starts an idea from the idea gear popover', () => {
- renderIssue();
- fireEvent.click(screen.getByRole('button', { name: 'Edit idea' }));
- fireEvent.click(screen.getByRole('button', { name: 'New idea' }));
+ test('starts an idea from the idea gear popover', async () => {
+ await renderIssue();
+ await page.getByRole('button', { name: 'Edit idea' }).click();
+ await page.getByRole('button', { name: 'New idea' }).click();
expect(actions.implement).toHaveBeenCalledOnce();
});
- test('hides sidebar actions the viewer cannot take', () => {
+ test('hides sidebar actions the viewer cannot take', async () => {
state.issue = issue({ canManage: false });
- renderIssue();
-
- expect(screen.getByText('No one')).toBeTruthy();
- expect(screen.getByText('None yet')).toBeTruthy();
- expect(screen.getByText('No date yet')).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Edit assignees' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Edit idea' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Close issue' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Decide by' })).toBeNull();
+ await renderIssue();
+
+ await expect.element(page.getByText('No one')).toBeInTheDocument();
+ await expect.element(page.getByText('None yet')).toBeInTheDocument();
+ await expect.element(page.getByText('No date yet')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Edit assignees' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Edit idea' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Close issue' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Decide by' })).not.toBeInTheDocument();
});
- test('keeps assign available on a closed issue without starting an idea', () => {
+ test('keeps assign available on a closed issue without starting an idea', async () => {
state.issue = issue({ status: 'closed' });
- renderIssue();
+ await renderIssue();
- expect(screen.getByRole('button', { name: 'Edit assignees' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Edit idea' })).toBeNull();
- expect(screen.getByText('None yet')).toBeTruthy();
+ await expect.element(page.getByRole('button', { name: 'Edit assignees' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Edit idea' })).not.toBeInTheDocument();
+ await expect.element(page.getByText('None yet')).toBeInTheDocument();
});
- test('renders later comments in the same thread as the opening body', () => {
+ test('renders later comments in the same thread as the opening body', async () => {
state.issue = issue({
comments: [
{
@@ -198,15 +195,17 @@ describe('IssueDetail', () => {
}
]
});
- renderIssue();
+ await renderIssue();
- expect(screen.getByText('Add a coast day near Lisbon.')).toBeTruthy();
- expect(screen.getByText('Jordan Lee')).toBeTruthy();
- expect(screen.getByText('Keep the afternoon free for the coast.')).toBeTruthy();
+ await expect.element(page.getByText('Add a coast day near Lisbon.')).toBeInTheDocument();
+ await expect.element(page.getByText('Jordan Lee')).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Keep the afternoon free for the coast.'))
+ .toBeInTheDocument();
expect(composerBadge()?.textContent).toBe('LD');
});
- test('renders assignment system events in the discussion thread', () => {
+ test('renders assignment system events in the discussion thread', async () => {
state.issue = issue({
comments: [
{
@@ -219,14 +218,16 @@ describe('IssueDetail', () => {
}
]
});
- renderIssue();
+ await renderIssue();
- expect(screen.getByText('Alex Morgan assigned this to Sam Planner')).toBeTruthy();
+ await expect
+ .element(page.getByText('Alex Morgan assigned this to Sam Planner'))
+ .toBeInTheDocument();
// Opening body is the only comment card; the assign event is a compact system line.
- expect(screen.getAllByText(/commented/u)).toHaveLength(1);
+ expect(page.getByText(/commented/u).elements()).toHaveLength(1);
});
- test('clusters consecutive assignment events onto one quiet rail marker', () => {
+ test('clusters consecutive assignment events onto one quiet rail marker', async () => {
state.issue = issue({
comments: [
{
@@ -255,11 +256,15 @@ describe('IssueDetail', () => {
}
]
});
- renderIssue();
+ await renderIssue();
- expect(screen.getByText('Alex Morgan assigned this to Sam Planner')).toBeTruthy();
- expect(screen.getByText('Alex Morgan unassigned Sam Planner')).toBeTruthy();
- expect(screen.getByText('Alex Morgan assigned this to Issue agent')).toBeTruthy();
+ await expect
+ .element(page.getByText('Alex Morgan assigned this to Sam Planner'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Alex Morgan unassigned Sam Planner')).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Alex Morgan assigned this to Issue agent'))
+ .toBeInTheDocument();
const timelineItems = document.querySelectorAll('[data-slot="timeline-item"]');
// Opening comment + one system cluster + composer.
@@ -267,40 +272,38 @@ describe('IssueDetail', () => {
expect(timelineItems[1]?.querySelectorAll('[data-slot="timeline-badge"]')).toHaveLength(1);
});
- test('shows a linked idea in the sidebar instead of starting a new one', () => {
+ test('shows a linked idea in the sidebar instead of starting a new one', async () => {
state.issue = issue({
idea: { id: 'proposal-1', status: 'in_review', title: 'Coast day draft' }
});
- renderIssue();
-
- expect(screen.getByText('Coast day draft')).toBeTruthy();
- expect(screen.getByText('in review')).toBeTruthy();
- expect(screen.getByRole('link', { name: /Coast day draft/u }).getAttribute('href')).toBe(
- '/trips/$tripId/ideas/$proposalId/$view'
- );
- expect(screen.queryByRole('button', { name: 'Edit idea' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'New idea' })).toBeNull();
+ await renderIssue();
+
+ await expect.element(page.getByText('Coast day draft')).toBeInTheDocument();
+ await expect.element(page.getByText('in review')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: /Coast day draft/u }))
+ .toHaveAttribute('href', '/trips/$tripId/ideas/$proposalId/$view');
+ await expect.element(page.getByRole('button', { name: 'Edit idea' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'New idea' })).not.toBeInTheDocument();
});
- test('shows an assigned person as a compact row', () => {
+ test('shows an assigned person as a compact row', async () => {
state.issue = issue({
assignee: { agentId: 'issue', kind: 'agent', name: 'Issue agent' }
});
- renderIssue();
+ await renderIssue();
- expect(screen.getByText('Issue agent')).toBeTruthy();
- expect(screen.queryByText('No one')).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: 'Edit assignees' }));
- fireEvent.click(screen.getByRole('button', { name: 'Unassign Issue agent' }));
+ await expect.element(page.getByText('Issue agent')).toBeInTheDocument();
+ await expect.element(page.getByText('No one')).not.toBeInTheDocument();
+ await page.getByRole('button', { name: 'Edit assignees' }).click();
+ await page.getByRole('button', { name: 'Unassign Issue agent' }).click();
expect(actions.unassign).toHaveBeenCalledOnce();
});
- test('submits a comment from the thread composer', () => {
- renderIssue();
- fireEvent.change(screen.getByLabelText('Issue comment'), {
- target: { value: 'Can we keep this flexible?' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Comment' }));
+ test('submits a comment from the thread composer', async () => {
+ await renderIssue();
+ await page.getByLabelText('Issue comment').fill('Can we keep this flexible?');
+ await page.getByRole('button', { name: 'Comment' }).click();
expect(actions.addComment).toHaveBeenCalledWith('Can we keep this flexible?');
});
});
diff --git a/apps/web/src/features/issues/issue-detail/issue-sidebar.tsx b/apps/web/src/features/issues/issue-detail/issue-sidebar.tsx
index 8e0f1879..30f8b12e 100644
--- a/apps/web/src/features/issues/issue-detail/issue-sidebar.tsx
+++ b/apps/web/src/features/issues/issue-detail/issue-sidebar.tsx
@@ -76,6 +76,8 @@ function AssigneeSection({
const assigned = issue.assignee?.kind === 'user' && issue.assignee.userId === member.userId;
return (
diff --git a/apps/web/src/features/issues/issue-list/issue-list-view.test.tsx b/apps/web/src/features/issues/issue-list/issue-list-view.test.tsx
index 26b57e1f..3999faf0 100644
--- a/apps/web/src/features/issues/issue-list/issue-list-view.test.tsx
+++ b/apps/web/src/features/issues/issue-list/issue-list-view.test.tsx
@@ -1,6 +1,7 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { WorkspaceIssue } from '@/features/issues/hooks/use-workspace-issues';
import { IssueListView } from './issue-list-view';
@@ -38,7 +39,8 @@ vi.mock('@/features/issues/hooks/use-workspace-issues', () => ({
})
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useNavigate: () => vi.fn()
}));
@@ -51,33 +53,37 @@ beforeEach(() => {
state.status = 'Exhausted';
});
-afterEach(cleanup);
-
describe('IssueListView', () => {
- test('shows a labeled loading state while issues load', () => {
+ test('shows a labeled loading state while issues load', async () => {
state.isLoading = true;
- render();
- expect(screen.getByRole('status', { name: 'Loading issues…' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByRole('status', { name: 'Loading issues…' })).toBeInTheDocument();
});
- test('invites the group to create an issue when none exist', () => {
- render();
- expect(screen.getByTestId('empty-screen')).toBeTruthy();
- expect(screen.getByText('Nothing is blocking the group yet')).toBeTruthy();
- expect(screen.getAllByRole('button', { name: 'New issue' })).toHaveLength(1);
- expect(screen.queryByText('Acme Labs')).toBeNull();
- expect(screen.getByRole('heading', { name: 'Issues' })).toBeTruthy();
+ test('invites the group to create an issue when none exist', async () => {
+ await render();
+ await expect.element(page.getByTestId('empty-screen')).toBeInTheDocument();
+ await expect.element(page.getByText('Nothing is blocking the group yet')).toBeInTheDocument();
+ expect(page.getByRole('button', { name: 'New issue' }).elements()).toHaveLength(1);
+ await expect.element(page.getByText('Acme Labs')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Issues' })).toBeInTheDocument();
});
- test('gives a way to reach trips when an issue cannot be created yet', () => {
- render();
- fireEvent.click(screen.getByRole('button', { name: 'New issue' }));
- expect(screen.getByRole('heading', { name: 'Start with a trip' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Go to trips' }).getAttribute('href')).toBe('/trips');
- expect(screen.queryByRole('button', { name: 'Create issue' })).toBeNull();
+ test('gives a way to reach trips when an issue cannot be created yet', async () => {
+ await render();
+ await page.getByRole('button', { name: 'New issue' }).click();
+ await expect
+ .element(page.getByRole('heading', { name: 'Start with a trip' }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Go to trips' }))
+ .toHaveAttribute('href', '/trips');
+ await expect
+ .element(page.getByRole('button', { name: 'Create issue' }))
+ .not.toBeInTheDocument();
});
- test('renders workspace issues with their trip names', () => {
+ test('renders workspace issues with their trip names', async () => {
state.issues = [
{
assignee: null,
@@ -94,12 +100,12 @@ describe('IssueListView', () => {
updatedAt: Date.UTC(2026, 7, 17)
}
];
- render();
- expect(screen.getByText('Coast day')).toBeTruthy();
- expect(screen.getAllByText(/Atlantic week/u)).toHaveLength(2);
- expect(screen.queryByText('Acme Labs')).toBeNull();
- expect(screen.getByRole('heading', { name: 'Issues' })).toBeTruthy();
- expect(screen.getByLabelText('Search issues')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'New issue' })).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Coast day')).toBeInTheDocument();
+ expect(page.getByText(/Atlantic week/u).elements()).toHaveLength(2);
+ await expect.element(page.getByText('Acme Labs')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('heading', { name: 'Issues' })).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search issues')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'New issue' })).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/issues/issue-list/issue-list.test.tsx b/apps/web/src/features/issues/issue-list/issue-list.test.tsx
index 385e393a..0aa915a2 100644
--- a/apps/web/src/features/issues/issue-list/issue-list.test.tsx
+++ b/apps/web/src/features/issues/issue-list/issue-list.test.tsx
@@ -1,20 +1,24 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
import type { ReactNode } from 'react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { expectPhoneListRow, expectWideListRow } from '@/testing/phone-list';
import { IssueList, type IssueListItem } from './issue-list';
-vi.mock('@tanstack/react-router', () => ({
+const navigate = vi.hoisted(() => vi.fn());
+
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, className, to }: { children?: ReactNode; className?: string; to: string }) => (
{children}
),
- useNavigate: () => vi.fn()
+ useNavigate: () => navigate
}));
-afterEach(cleanup);
-
const issues: IssueListItem[] = [
{
assignee: null,
@@ -38,7 +42,7 @@ const issues: IssueListItem[] = [
}
];
-function renderList() {
+async function renderList() {
return render(
@@ -53,31 +57,55 @@ function renderList() {
);
}
-test('renders a compact search-and-status toolbar instead of stacked filter chrome', () => {
- renderList();
+test('renders a compact search-and-status toolbar instead of stacked filter chrome', async () => {
+ await renderList();
const toolbar = document.querySelector('[aria-label="Filter issues"]');
expect(toolbar?.className).toContain('flex');
expect(toolbar?.className).toContain('items-center');
expect(toolbar?.className).not.toContain('dashboard-panel');
expect(document.querySelector('table')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Issue' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: /Open \d+/u })).toBeNull();
- expect(screen.queryByRole('button', { name: /Closed \d+/u })).toBeNull();
- expect(screen.getByLabelText('Search issues')).toBeTruthy();
- expect(screen.getByLabelText('Filter issues by status').textContent).toContain('Open');
- expect(screen.getByText('Coast day')).toBeTruthy();
- expect(screen.getByText('Museum morning')).toBeTruthy();
+ await expect.element(page.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Open \d+/u })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Closed \d+/u })).not.toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search issues')).toBeInTheDocument();
+ expect(page.getByLabelText('Filter issues by status').element().textContent).toContain('Open');
+ await expect.element(page.getByText('Coast day')).toBeInTheDocument();
+ await expect.element(page.getByText('Museum morning')).toBeInTheDocument();
});
-test('searches visible issue fields and can clear the filter', () => {
- renderList();
+test('searches visible issue fields and can clear the filter', async () => {
+ await renderList();
+
+ await page.getByLabelText('Search issues').fill('museum');
+ await expect.element(page.getByText('Coast day')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Museum morning')).toBeInTheDocument();
+
+ await page.getByRole('button', { name: 'Clear issue filters' }).click();
+ await expect.element(page.getByText('Coast day')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search issues')).toHaveValue('');
+});
+
+describe('on a phone', () => {
+ const firstRow = () =>
+ page
+ .getByRole('row')
+ .filter({ has: page.getByRole('link', { name: /Coast day|Museum morning/u }) })
+ .first();
+
+ test('keeps titles readable, drops secondary columns and opens a row on tap', async () => {
+ await useViewport(390, 844);
+ await renderList();
+ await expectPhoneListRow(firstRow());
+ await expect.element(page.getByText('Opened by')).not.toBeVisible();
- fireEvent.change(screen.getByLabelText('Search issues'), { target: { value: 'museum' } });
- expect(screen.queryByText('Coast day')).toBeNull();
- expect(screen.getByText('Museum morning')).toBeTruthy();
+ // The title is a real link, so a tap navigates without needing row activation.
+ await expect.element(firstRow().getByRole('link')).toHaveAttribute('href', '/issues/$issueId');
+ });
- fireEvent.click(screen.getByRole('button', { name: 'Clear issue filters' }));
- expect(screen.getByText('Coast day')).toBeTruthy();
- expect((screen.getByLabelText('Search issues') as HTMLInputElement).value).toBe('');
+ test('shows the author and assignee columns again on a wide screen', async () => {
+ await renderList();
+ await expectWideListRow(firstRow());
+ await expect.element(page.getByText('Opened by')).toBeVisible();
+ });
});
diff --git a/apps/web/src/features/issues/issues-sidebar-nav.test.tsx b/apps/web/src/features/issues/issues-sidebar-nav.test.tsx
index 6fcb8913..3ee30f9d 100644
--- a/apps/web/src/features/issues/issues-sidebar-nav.test.tsx
+++ b/apps/web/src/features/issues/issues-sidebar-nav.test.tsx
@@ -1,9 +1,9 @@
import { SidebarProvider } from '@groam/ui/components/sidebar';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { resetSidebarChromeStore, useSidebarChromeStore } from '@/lib/stores/sidebar-chrome-store';
-import { stubMatchMedia } from '@/testing/stub-match-media';
import type { WorkspaceIssue } from './hooks/use-workspace-issues';
import { IssuesSidebarNav } from './issues-sidebar-nav';
@@ -17,7 +17,8 @@ const state = vi.hoisted(() => ({
trips: [] as Array<{ archivedAt: null; id: string; name: string }>
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
useLocation: () => ({ pathname: state.pathname }),
useNavigate: () => vi.fn(),
@@ -59,7 +60,7 @@ function issue(
};
}
-function renderNav() {
+async function renderNav() {
return render(
@@ -80,22 +81,16 @@ beforeEach(() => {
state.pathname = '/issues';
state.status = 'Exhausted';
state.trips = [];
- stubMatchMedia();
});
-afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
+test('offers a new issue item when the inbox is empty', async () => {
+ await renderNav();
+ await expect.element(page.getByRole('button', { name: 'New issue' })).not.toBeInTheDocument();
+ await page.getByText('New issue').click();
+ await expect.element(page.getByText('Start with a trip')).toBeInTheDocument();
});
-test('offers a new issue item when the inbox is empty', () => {
- renderNav();
- expect(screen.queryByRole('button', { name: 'New issue' })).toBeNull();
- fireEvent.click(screen.getByText('New issue'));
- expect(screen.getByText('Start with a trip')).toBeTruthy();
-});
-
-test('collapses listed issues from the parent action without leaving the index link', () => {
+test('collapses listed issues from the parent action without leaving the index link', async () => {
state.issues = [
issue({
id: 'issue-1' as WorkspaceIssue['id'],
@@ -103,19 +98,21 @@ test('collapses listed issues from the parent action without leaving the index l
})
];
- renderNav();
- expect(screen.getByText('Issue 1')).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Issues' }).getAttribute('href')).toBe('/issues');
+ await renderNav();
+ await expect.element(page.getByText('Issue 1')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Issues' }))
+ .toHaveAttribute('href', '/issues');
- fireEvent.click(screen.getByRole('link', { name: 'Issues' }));
- expect(screen.getByText('Issue 1')).toBeTruthy();
+ await page.getByRole('link', { name: 'Issues' }).click();
+ await expect.element(page.getByText('Issue 1')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Collapse issues' }));
- expect(screen.queryByText('Issue 1')).toBeNull();
- expect(screen.getByRole('button', { name: 'Expand issues' })).toBeTruthy();
+ await page.getByRole('button', { name: 'Collapse issues' }).click();
+ await expect.element(page.getByText('Issue 1')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Expand issues' })).toBeInTheDocument();
});
-test('lists open issues and can reveal more than the first page', () => {
+test('lists open issues and can reveal more than the first page', async () => {
state.issues = Array.from({ length: 6 }, (_, index) =>
issue({
id: `issue-${index}` as WorkspaceIssue['id'],
@@ -123,9 +120,9 @@ test('lists open issues and can reveal more than the first page', () => {
})
);
- renderNav();
- expect(screen.getByText('Issue 1')).toBeTruthy();
- expect(screen.queryByText('Issue 6')).toBeNull();
- fireEvent.click(screen.getByText('Load more'));
- expect(screen.getByText('Issue 6')).toBeTruthy();
+ await renderNav();
+ await expect.element(page.getByText('Issue 1')).toBeInTheDocument();
+ await expect.element(page.getByText('Issue 6')).not.toBeInTheDocument();
+ await page.getByText('Load more').click();
+ await expect.element(page.getByText('Issue 6')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/media/hooks/use-media-upload.test.tsx b/apps/web/src/features/media/hooks/use-media-upload.test.tsx
index 7903170a..2b35ce9d 100644
--- a/apps/web/src/features/media/hooks/use-media-upload.test.tsx
+++ b/apps/web/src/features/media/hooks/use-media-upload.test.tsx
@@ -1,5 +1,5 @@
import { env } from '@groam/env/web-client';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useMediaUpload } from './use-media-upload';
@@ -11,7 +11,8 @@ const convex = vi.hoisted(() => ({
}));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useAction: (...args: unknown[]) => convex.useAction(...args),
useMutation: (...args: unknown[]) => convex.useMutation(...args)
}));
@@ -31,7 +32,9 @@ beforeEach(() => {
convex.save.mockResolvedValue({ mediaId: 'media-a', ok: true });
});
-afterEach(() => vi.unstubAllGlobals());
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
function stubUploadResponse(body: unknown, status = 200) {
const response = new Response(JSON.stringify(body), { status });
@@ -41,7 +44,7 @@ function stubUploadResponse(body: unknown, status = 200) {
describe('useMediaUpload', () => {
test('rejects unsupported files before requesting an upload URL', async () => {
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
const file = new File(['notes'], 'notes.txt', { type: 'text/plain' });
await expect(act(() => result.current(file))).resolves.toBeNull();
@@ -52,9 +55,10 @@ describe('useMediaUpload', () => {
test('reports oversized files through the error callback', async () => {
const onError = vi.fn();
- const { result } = renderHook(() => useMediaUpload());
- const file = new File(['image'], 'huge.png', { type: 'image/png' });
- Object.defineProperty(file, 'size', { value: 25 * 1024 * 1024 + 1 });
+ const { result } = await renderHook(() => useMediaUpload());
+ const file = new File([new Uint8Array(25 * 1024 * 1024 + 1)], 'huge.png', {
+ type: 'image/png'
+ });
await expect(
act(() => result.current(file, `${file.name} uploaded`, onError))
@@ -67,7 +71,7 @@ describe('useMediaUpload', () => {
test('uploads to Convex storage and saves the resulting media id', async () => {
stubUploadResponse({ storageId: 'storage-a' });
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(act(() => result.current(pngFile))).resolves.toBe('media-a');
@@ -82,7 +86,7 @@ describe('useMediaUpload', () => {
test('can skip the success toast when a caller supplies a null message', async () => {
stubUploadResponse({ storageId: 'storage-a' });
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(act(() => result.current(pngFile, null))).resolves.toBe('media-a');
expect(notifications.success).not.toHaveBeenCalled();
@@ -90,7 +94,7 @@ describe('useMediaUpload', () => {
test('surfaces malformed Convex upload responses', async () => {
stubUploadResponse({});
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(act(() => result.current(pngFile))).resolves.toBeNull();
@@ -101,7 +105,7 @@ describe('useMediaUpload', () => {
test('surfaces HTTP upload failures', async () => {
stubUploadResponse({ storageId: 'storage-a' }, 500);
const onError = vi.fn();
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(
act(() => result.current(pngFile, `${pngFile.name} uploaded`, onError))
@@ -115,7 +119,7 @@ describe('useMediaUpload', () => {
test('surfaces Convex save errors without claiming success', async () => {
stubUploadResponse({ storageId: 'storage-a' });
convex.save.mockResolvedValue({ error: 'The uploaded file was not found', ok: false });
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(act(() => result.current(pngFile))).resolves.toBeNull();
@@ -125,7 +129,7 @@ describe('useMediaUpload', () => {
test('surfaces thrown upload URL failures', async () => {
convex.generateUploadUrl.mockRejectedValue(new Error('Not signed in'));
- const { result } = renderHook(() => useMediaUpload());
+ const { result } = await renderHook(() => useMediaUpload());
await expect(act(() => result.current(pngFile))).resolves.toBeNull();
diff --git a/apps/web/src/features/settings/hooks/use-account-recovery-settings.test.tsx b/apps/web/src/features/settings/hooks/use-account-recovery-settings.test.tsx
index 561dd25c..2afb0629 100644
--- a/apps/web/src/features/settings/hooks/use-account-recovery-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-account-recovery-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useAccountRecoverySettings } from './use-account-recovery-settings';
@@ -17,7 +17,7 @@ beforeEach(() => {
});
test('verifies the current password and downloads a newly generated code set', async () => {
- const { result } = renderHook(() => useAccountRecoverySettings());
+ const { result } = await renderHook(() => useAccountRecoverySettings());
await act(() => result.current.generate('current-password'));
@@ -33,7 +33,7 @@ test('verifies the current password and downloads a newly generated code set', a
test('surfaces generation failures without downloading a file', async () => {
auth.generate.mockResolvedValue({ data: null, error: { message: 'Invalid password' } });
- const { result } = renderHook(() => useAccountRecoverySettings());
+ const { result } = await renderHook(() => useAccountRecoverySettings());
await act(() => result.current.generate('wrong-password'));
diff --git a/apps/web/src/features/settings/hooks/use-ai-availability.test.tsx b/apps/web/src/features/settings/hooks/use-ai-availability.test.tsx
index 0db4c935..0058462b 100644
--- a/apps/web/src/features/settings/hooks/use-ai-availability.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-ai-availability.test.tsx
@@ -1,10 +1,11 @@
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useAiAvailability } from './use-ai-availability';
const convex = vi.hoisted(() => ({ useQuery: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -12,20 +13,20 @@ beforeEach(() => {
convex.useQuery.mockReset();
});
-test('hides the AI tab while deployment availability is loading', () => {
+test('hides the AI tab while deployment availability is loading', async () => {
convex.useQuery.mockReturnValue(undefined);
- const { result } = renderHook(() => useAiAvailability());
+ const { result } = await renderHook(() => useAiAvailability());
expect(result.current).toEqual({ environmentConfigured: false, hideAi: true, isLoading: true });
});
-test('hides the AI tab for a deployment-managed provider', () => {
+test('hides the AI tab for a deployment-managed provider', async () => {
convex.useQuery.mockReturnValue({ environmentConfigured: true });
- const { result } = renderHook(() => useAiAvailability());
+ const { result } = await renderHook(() => useAiAvailability());
expect(result.current).toEqual({ environmentConfigured: true, hideAi: true, isLoading: false });
});
-test('shows the AI tab when users may manage credentials', () => {
+test('shows the AI tab when users may manage credentials', async () => {
convex.useQuery.mockReturnValue({ environmentConfigured: false });
- const { result } = renderHook(() => useAiAvailability());
+ const { result } = await renderHook(() => useAiAvailability());
expect(result.current).toEqual({ environmentConfigured: false, hideAi: false, isLoading: false });
});
diff --git a/apps/web/src/features/settings/hooks/use-ai-provider-auth.test.tsx b/apps/web/src/features/settings/hooks/use-ai-provider-auth.test.tsx
index 992936fe..b870e16f 100644
--- a/apps/web/src/features/settings/hooks/use-ai-provider-auth.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-ai-provider-auth.test.tsx
@@ -1,11 +1,12 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useAiProviderAuthStore, useAiSettingsDraftStore } from '@/lib/stores/settings-stores';
import { useAiProviderAuth } from './use-ai-provider-auth';
const convex = vi.hoisted(() => ({ save: vi.fn() }));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: () => convex.save
}));
@@ -43,9 +44,9 @@ describe('useAiProviderAuth', () => {
)
);
- renderHook(() => useAiProviderAuth('openrouter', target, organizationId));
+ await renderHook(() => useAiProviderAuth('openrouter', target, organizationId));
- await waitFor(() =>
+ await vi.waitFor(() =>
expect(convex.save).toHaveBeenCalledWith({
apiKey: `${target}-key`,
baseUrl: null,
@@ -62,15 +63,15 @@ describe('useAiProviderAuth', () => {
}
);
- test('returns no connection for a provider without an authentication strategy', () => {
- const { result } = renderHook(() => useAiProviderAuth('openai', 'personal', null));
+ test('returns no connection for a provider without an authentication strategy', async () => {
+ const { result } = await renderHook(() => useAiProviderAuth('openai', 'personal', null));
expect(result.current).toBeNull();
expect(convex.save).not.toHaveBeenCalled();
});
test('binds a new organization connection to its initiating organization', async () => {
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useAiProviderAuth('openrouter', 'organization', 'organization-a')
);
@@ -85,7 +86,7 @@ describe('useAiProviderAuth', () => {
test('surfaces failures while starting a connection', async () => {
vi.spyOn(crypto.subtle, 'digest').mockRejectedValueOnce(new Error('Crypto unavailable'));
- const { result } = renderHook(() => useAiProviderAuth('openrouter', 'personal', null));
+ const { result } = await renderHook(() => useAiProviderAuth('openrouter', 'personal', null));
await act(() => result.current?.connect());
@@ -109,9 +110,9 @@ describe('useAiProviderAuth', () => {
);
vi.stubGlobal('fetch', fetchMock);
convex.save.mockRejectedValueOnce(new Error('Network unavailable'));
- const { result } = renderHook(() => useAiProviderAuth('openai', 'personal', null));
+ const { result } = await renderHook(() => useAiProviderAuth('openai', 'personal', null));
- await waitFor(() =>
+ await vi.waitFor(() =>
expect(useAiSettingsDraftStore.getState().error).toBe('Network unavailable')
);
expect(useAiProviderAuthStore.getState().session).not.toBeNull();
@@ -134,9 +135,11 @@ describe('useAiProviderAuth', () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
- renderHook(() => useAiProviderAuth('openai', 'organization', 'organization-b'));
+ await renderHook(() => useAiProviderAuth('openai', 'organization', 'organization-b'));
- await waitFor(() => expect(useAiSettingsDraftStore.getState().error).toContain('Switch back'));
+ await vi.waitFor(() =>
+ expect(useAiSettingsDraftStore.getState().error).toContain('Switch back')
+ );
expect(fetchMock).not.toHaveBeenCalled();
expect(window.location.search).toBe('?code=organization-code');
expect(useAiProviderAuthStore.getState().session).not.toBeNull();
diff --git a/apps/web/src/features/settings/hooks/use-ai-settings.test.tsx b/apps/web/src/features/settings/hooks/use-ai-settings.test.tsx
index ce7d4bfa..ea184657 100644
--- a/apps/web/src/features/settings/hooks/use-ai-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-ai-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useAiProviderAuthStore, useAiSettingsDraftStore } from '@/lib/stores/settings-stores';
import { useAiSettings } from './use-ai-settings';
@@ -9,7 +9,8 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useMutation: (...args: unknown[]) => convex.useMutation(...args),
useQuery: (...args: unknown[]) => convex.useQuery(...args)
}));
@@ -65,14 +66,14 @@ beforeEach(() => {
});
describe('useAiSettings', () => {
- test('recommends OpenRouter when the user has no personal connection', () => {
- const { result } = renderHook(() => useAiSettings());
+ test('recommends OpenRouter when the user has no personal connection', async () => {
+ const { result } = await renderHook(() => useAiSettings());
expect(result.current.provider).toBe('openrouter');
});
test('saves an OpenRouter key and optional model', async () => {
- const { result } = renderHook(() => useAiSettings());
- act(() =>
+ const { result } = await renderHook(() => useAiSettings());
+ await act(() =>
result.current.updateDraft({
apiKey: ' sk-or-test ',
model: ' anthropic/claude-sonnet-4 ',
@@ -100,7 +101,7 @@ describe('useAiSettings', () => {
convex.useQuery.mockReturnValue(
remoteSettings({ personal: { configured: true, provider: 'openai' } })
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
await act(() => result.current.remove());
@@ -122,10 +123,10 @@ describe('useAiSettings', () => {
organization: { configured: true, provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
- act(() => result.current.setTarget('organization'));
- act(() => result.current.updateDraft({ apiKey: 'sk-shared-new' }));
+ await act(() => result.current.setTarget('organization'));
+ await act(() => result.current.updateDraft({ apiKey: 'sk-shared-new' }));
await act(() => result.current.save());
expect(convex.save).toHaveBeenLastCalledWith({
apiKey: 'sk-shared-new',
@@ -147,32 +148,32 @@ describe('useAiSettings', () => {
});
});
- test('does not let a member select the organization target', () => {
+ test('does not let a member select the organization target', async () => {
convex.useQuery.mockReturnValue(remoteSettings({ canManageOrganization: false }));
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
- act(() => result.current.setTarget('organization'));
+ await act(() => result.current.setTarget('organization'));
expect(result.current.target).toBe('personal');
});
- test('uses the backend-selected personal source over an organization fallback', () => {
+ test('uses the backend-selected personal source over an organization fallback', async () => {
convex.useQuery.mockReturnValue(
remoteSettings({
organization: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' },
personal: { configured: true, model: 'claude-sonnet-4-5', provider: 'anthropic' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
expect(result.current.effectiveSource).toBe('personal');
expect(result.current.effectiveProvider).toBe('anthropic');
expect(result.current.effectiveModel).toBe('claude-sonnet-4-5');
});
- test('exposes when Convex env is supplying AI credentials', () => {
+ test('exposes when Convex env is supplying AI credentials', async () => {
convex.useQuery.mockReturnValue(remoteSettings({ environmentConfigured: true }));
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
expect(result.current.fromEnvironment).toBe(true);
expect(result.current.configured).toBe(false);
});
@@ -181,7 +182,7 @@ describe('useAiSettings', () => {
convex.save.mockRejectedValue(
new Error('Only organization owners and admins can manage group settings')
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
await act(() => result.current.save());
@@ -190,13 +191,13 @@ describe('useAiSettings', () => {
);
});
- test('keeps the saved model while the provider stays the same', () => {
+ test('keeps the saved model while the provider stays the same', async () => {
convex.useQuery.mockReturnValue(
remoteSettings({
personal: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
expect(result.current.provider).toBe('openai');
expect(result.current.model).toBe('gpt-4.1-mini');
});
@@ -207,13 +208,13 @@ describe('useAiSettings', () => {
personal: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
- act(() => result.current.selectProvider('openrouter'));
+ await act(() => result.current.selectProvider('openrouter'));
expect(result.current.provider).toBe('openrouter');
expect(result.current.model).toBe('');
- act(() => result.current.updateDraft({ apiKey: 'sk-or-new' }));
+ await act(() => result.current.updateDraft({ apiKey: 'sk-or-new' }));
await act(() => result.current.save());
expect(convex.save).toHaveBeenCalledWith({
@@ -230,10 +231,10 @@ describe('useAiSettings', () => {
personal: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
expect(result.current.canSave).toBe(true);
- act(() => result.current.updateDraft({ model: 'gpt-4.1' }));
+ await act(() => result.current.updateDraft({ model: 'gpt-4.1' }));
await act(() => result.current.save());
expect(convex.save).toHaveBeenCalledWith({
@@ -245,37 +246,37 @@ describe('useAiSettings', () => {
expect(result.current.draft.message).toBe('AI settings saved.');
});
- test('requires a new key after switching providers', () => {
+ test('requires a new key after switching providers', async () => {
convex.useQuery.mockReturnValue(
remoteSettings({
personal: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
- act(() => result.current.selectProvider('anthropic'));
+ await act(() => result.current.selectProvider('anthropic'));
expect(result.current.canSave).toBe(false);
});
- test('requires a new key after switching from OpenAI to a local host', () => {
+ test('requires a new key after switching from OpenAI to a local host', async () => {
convex.useQuery.mockReturnValue(
remoteSettings({
personal: { configured: true, model: 'gpt-4.1-mini', provider: 'openai' }
})
);
- const { result } = renderHook(() => useAiSettings());
+ const { result } = await renderHook(() => useAiSettings());
- act(() => result.current.selectProvider('compatible'));
+ await act(() => result.current.selectProvider('compatible'));
expect(result.current.canSave).toBe(false);
});
test('saves a local OpenAI-compatible host with a base URL', async () => {
- const { result } = renderHook(() => useAiSettings());
- act(() => result.current.selectProvider('compatible'));
+ const { result } = await renderHook(() => useAiSettings());
+ await act(() => result.current.selectProvider('compatible'));
expect(result.current.canSave).toBe(true);
expect(result.current.baseUrl).toBe('http://127.0.0.1:11434/v1');
- act(() =>
+ await act(() =>
result.current.updateDraft({
apiKey: 'ollama',
baseUrl: ' http://127.0.0.1:1234/v1 ',
diff --git a/apps/web/src/features/settings/hooks/use-organization-logo.test.tsx b/apps/web/src/features/settings/hooks/use-organization-logo.test.tsx
index a695ba1c..07972246 100644
--- a/apps/web/src/features/settings/hooks/use-organization-logo.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-organization-logo.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useOrganizationLogo } from './use-organization-logo';
@@ -7,7 +7,10 @@ const convex = vi.hoisted(() => ({ mutations: [] as Array ({ upload: vi.fn() }));
vi.mock('@groam/auth/client', () => ({ authClient: { organization: { update: auth.update } } }));
-vi.mock('convex/react', () => ({ useMutation: () => convex.mutations.shift() }));
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useMutation: () => convex.mutations.shift()
+}));
vi.mock('@/features/media/hooks/use-media-upload', () => ({ useMediaUpload: () => media.upload }));
beforeEach(() => {
@@ -19,7 +22,7 @@ beforeEach(() => {
describe('useOrganizationLogo', () => {
test('rejects non-image files before uploading', async () => {
- const { result } = renderHook(() => useOrganizationLogo('organization-1'));
+ const { result } = await renderHook(() => useOrganizationLogo('organization-1'));
await expect(
act(() => result.current.upload(new File(['x'], 'notes.txt', { type: 'text/plain' })))
@@ -30,7 +33,7 @@ describe('useOrganizationLogo', () => {
test('uploads media, stores the logo, and refreshes the active organization', async () => {
const [setLogo] = convex.mutations;
setLogo?.mockResolvedValue({ url: 'https://cdn.example/logo.png' });
- const { result } = renderHook(() => useOrganizationLogo('organization-1'));
+ const { result } = await renderHook(() => useOrganizationLogo('organization-1'));
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
await expect(act(() => result.current.upload(file))).resolves.toBe(
@@ -47,7 +50,7 @@ describe('useOrganizationLogo', () => {
test('does not set a logo when media upload returns no id and clears via both stores', async () => {
media.upload.mockResolvedValueOnce(null);
const [, clearLogo] = convex.mutations;
- const { result } = renderHook(() => useOrganizationLogo('organization-1'));
+ const { result } = await renderHook(() => useOrganizationLogo('organization-1'));
await expect(
act(() => result.current.upload(new File(['logo'], 'logo.png', { type: 'image/png' })))
diff --git a/apps/web/src/features/settings/hooks/use-organization-settings.test.tsx b/apps/web/src/features/settings/hooks/use-organization-settings.test.tsx
index 7b61a12d..33599489 100644
--- a/apps/web/src/features/settings/hooks/use-organization-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-organization-settings.test.tsx
@@ -1,9 +1,10 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { ActiveOrganization } from '@/features/workspace/workspace-shell/workspace-state';
import { useOrganizationSettings } from './use-organization-settings';
const auth = vi.hoisted(() => ({
+ getSession: vi.fn().mockResolvedValue({ data: null, error: null }),
list: vi.fn(),
remove: vi.fn(),
setActive: vi.fn(),
@@ -13,6 +14,7 @@ const logo = vi.hoisted(() => ({ clear: vi.fn(), upload: vi.fn() }));
vi.mock('@groam/auth/client', () => ({
authClient: {
+ getSession: auth.getSession,
organization: {
delete: auth.remove,
list: auth.list,
@@ -47,8 +49,8 @@ beforeEach(() => {
describe('useOrganizationSettings', () => {
test('trims and persists edited organization settings', async () => {
- const { result } = renderHook(() => useOrganizationSettings(organization));
- act(() =>
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
+ await act(() =>
result.current.updateState({
name: ' Updated name ',
slug: ' updated-slug '
@@ -73,7 +75,7 @@ describe('useOrganizationSettings', () => {
test('surfaces Better Auth errors without leaving the form pending', async () => {
auth.update.mockResolvedValue({ data: null, error: { message: 'Slug already exists' } });
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
await act(() => result.current.save());
@@ -85,7 +87,7 @@ describe('useOrganizationSettings', () => {
});
test('uploads and references a Convex media logo', async () => {
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(() => result.current.uploadLogo(file));
@@ -98,8 +100,8 @@ describe('useOrganizationSettings', () => {
});
test('clears the organization logo through the media hook', async () => {
- const { result } = renderHook(() => useOrganizationSettings(organization));
- act(() => result.current.updateState({ logo: 'https://example.com/group.png' }));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
+ await act(() => result.current.updateState({ logo: 'https://example.com/group.png' }));
await act(() => result.current.clearLogo());
@@ -113,7 +115,7 @@ describe('useOrganizationSettings', () => {
test('keeps the form usable when uploading or clearing a logo fails', async () => {
logo.upload.mockRejectedValueOnce(new Error('Server Error'));
logo.clear.mockRejectedValueOnce(new Error('Storage unavailable'));
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(() => result.current.uploadLogo(file));
@@ -132,7 +134,7 @@ describe('useOrganizationSettings', () => {
});
test('switches to a fallback before deleting the selected organization', async () => {
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
await act(() => result.current.remove());
@@ -145,7 +147,7 @@ describe('useOrganizationSettings', () => {
test('deletes the final organization without switching first', async () => {
auth.list.mockResolvedValue({ data: [organization], error: null });
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
await act(() => result.current.remove());
@@ -155,7 +157,7 @@ describe('useOrganizationSettings', () => {
});
test('surfaces list, activation, and deletion failures when removing a group', async () => {
- const { result } = renderHook(() => useOrganizationSettings(organization));
+ const { result } = await renderHook(() => useOrganizationSettings(organization));
auth.list.mockResolvedValueOnce({ data: null, error: { message: 'Cannot list groups' } });
await act(() => result.current.remove());
diff --git a/apps/web/src/features/settings/hooks/use-organization-settings.ts b/apps/web/src/features/settings/hooks/use-organization-settings.ts
index 0fbd8b8d..e8b120b3 100644
--- a/apps/web/src/features/settings/hooks/use-organization-settings.ts
+++ b/apps/web/src/features/settings/hooks/use-organization-settings.ts
@@ -1,6 +1,7 @@
import { authClient } from '@groam/auth/client';
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
+import { activateOrganization } from '@/features/workspace/hooks/use-organization-activation';
import type { ActiveOrganization } from '@/features/workspace/workspace-shell/workspace-state';
import { errorMessage } from '@/lib/errors';
import { useOrganizationSettingsStore } from '@/lib/stores/settings-stores';
@@ -92,8 +93,3 @@ async function removeOrganization(organizationId: string) {
const result = await authClient.organization.delete({ organizationId });
if (result.error) throw new Error(result.error.message ?? 'Unable to delete group');
}
-
-async function activateOrganization(organizationId: string) {
- const activation = await authClient.organization.setActive({ organizationId });
- if (activation.error) throw new Error(activation.error.message ?? 'Unable to switch groups');
-}
diff --git a/apps/web/src/features/settings/hooks/use-passkey-settings.test.tsx b/apps/web/src/features/settings/hooks/use-passkey-settings.test.tsx
index 5f617382..689ad17d 100644
--- a/apps/web/src/features/settings/hooks/use-passkey-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-passkey-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { usePasskeySettings } from './use-passkey-settings';
@@ -26,9 +26,9 @@ beforeEach(() => {
describe('usePasskeySettings', () => {
test('loads registered passkeys', async () => {
- const { result } = renderHook(() => usePasskeySettings());
+ const { result } = await renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.passkeys).toEqual([laptop]);
expect(result.current.error).toBeNull();
@@ -36,25 +36,25 @@ describe('usePasskeySettings', () => {
test('loads an empty list when the provider returns no data', async () => {
auth.listUserPasskeys.mockResolvedValue({ data: null, error: null });
- const { result } = renderHook(() => usePasskeySettings());
+ const { result } = await renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.passkeys).toEqual([]);
});
test('surfaces passkey loading errors', async () => {
auth.listUserPasskeys.mockResolvedValue({ data: null, error: { message: 'Loading failed' } });
- const { result } = renderHook(() => usePasskeySettings());
+ const { result } = await renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBe('Loading failed');
});
test('adds a named passkey and refreshes the list', async () => {
- const { result } = renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ const { result } = await renderHook(() => usePasskeySettings());
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
await act(() => result.current.add(' Phone '));
@@ -65,8 +65,8 @@ describe('usePasskeySettings', () => {
test('uses the default passkey name and reports registration failures', async () => {
auth.addPasskey.mockResolvedValue({ data: null, error: { message: 'Registration failed' } });
- const { result } = renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ const { result } = await renderHook(() => usePasskeySettings());
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
await act(() => result.current.add(' '));
@@ -75,8 +75,8 @@ describe('usePasskeySettings', () => {
});
test('removes a passkey from local state', async () => {
- const { result } = renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ const { result } = await renderHook(() => usePasskeySettings());
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
await act(() => result.current.remove(laptop.id));
@@ -87,8 +87,8 @@ describe('usePasskeySettings', () => {
test('keeps the passkey list when removal fails', async () => {
auth.deletePasskey.mockResolvedValue({ data: null, error: { message: 'Removal failed' } });
- const { result } = renderHook(() => usePasskeySettings());
- await waitFor(() => expect(result.current.isLoading).toBe(false));
+ const { result } = await renderHook(() => usePasskeySettings());
+ await vi.waitFor(() => expect(result.current.isLoading).toBe(false));
await act(() => result.current.remove(laptop.id));
diff --git a/apps/web/src/features/settings/hooks/use-password-settings.test.tsx b/apps/web/src/features/settings/hooks/use-password-settings.test.tsx
index 74dbe8cf..b759d040 100644
--- a/apps/web/src/features/settings/hooks/use-password-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-password-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { usePasswordSettings } from './use-password-settings';
@@ -15,8 +15,8 @@ beforeEach(() => {
describe('usePasswordSettings', () => {
test('changes the password and clears sensitive fields', async () => {
- const { result } = renderHook(() => usePasswordSettings());
- act(() =>
+ const { result } = await renderHook(() => usePasswordSettings());
+ await act(() =>
result.current.updateState({
confirmPassword: 'new-password',
currentPassword: 'old-password',
@@ -42,8 +42,8 @@ describe('usePasswordSettings', () => {
});
test('rejects mismatched passwords without calling auth', async () => {
- const { result } = renderHook(() => usePasswordSettings());
- act(() =>
+ const { result } = await renderHook(() => usePasswordSettings());
+ await act(() =>
result.current.updateState({
confirmPassword: 'another-password',
currentPassword: 'old-password',
@@ -58,8 +58,8 @@ describe('usePasswordSettings', () => {
});
test('rejects short passwords without calling auth', async () => {
- const { result } = renderHook(() => usePasswordSettings());
- act(() =>
+ const { result } = await renderHook(() => usePasswordSettings());
+ await act(() =>
result.current.updateState({
confirmPassword: 'short',
currentPassword: 'old-password',
@@ -78,8 +78,8 @@ describe('usePasswordSettings', () => {
data: null,
error: { message: 'Current password is wrong' }
});
- const { result } = renderHook(() => usePasswordSettings());
- act(() =>
+ const { result } = await renderHook(() => usePasswordSettings());
+ await act(() =>
result.current.updateState({
confirmPassword: 'new-password',
currentPassword: 'wrong',
diff --git a/apps/web/src/features/settings/hooks/use-password-settings.ts b/apps/web/src/features/settings/hooks/use-password-settings.ts
index 68fdf138..8c94907b 100644
--- a/apps/web/src/features/settings/hooks/use-password-settings.ts
+++ b/apps/web/src/features/settings/hooks/use-password-settings.ts
@@ -1,5 +1,7 @@
import { authClient } from '@groam/auth/client';
+import { use } from 'react';
import { useShallow } from 'zustand/react/shallow';
+import { SessionChangeContext } from '@/features/auth/session-change-context';
import { errorMessage } from '@/lib/errors';
import { usePasswordSettingsStore } from '@/lib/stores/settings-stores';
@@ -15,6 +17,7 @@ export function usePasswordSettings() {
}))
);
const patch = usePasswordSettingsStore((store) => store.patch);
+ const changeSession = use(SessionChangeContext);
const save = async () => {
if (state.newPassword.length < 8) {
@@ -28,11 +31,14 @@ export function usePasswordSettings() {
patch({ error: null, isPending: true, message: null });
try {
- const result = await authClient.changePassword({
- currentPassword: state.currentPassword,
- newPassword: state.newPassword,
- revokeOtherSessions: true
- });
+ // Revoking the other sessions replaces this one too.
+ const result = await changeSession(() =>
+ authClient.changePassword({
+ currentPassword: state.currentPassword,
+ newPassword: state.newPassword,
+ revokeOtherSessions: true
+ })
+ );
if (result.error) throw new Error(result.error.message ?? 'Unable to change password');
patch({
confirmPassword: '',
diff --git a/apps/web/src/features/settings/hooks/use-profile-settings.test.tsx b/apps/web/src/features/settings/hooks/use-profile-settings.test.tsx
index dc7ee548..e0951feb 100644
--- a/apps/web/src/features/settings/hooks/use-profile-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-profile-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { Session } from '@/features/workspace/workspace-shell/workspace-state';
import { useProfileSettings } from './use-profile-settings';
@@ -23,8 +23,8 @@ beforeEach(() => {
describe('useProfileSettings', () => {
test('trims and persists the profile name and image', async () => {
- const { result } = renderHook(() => useProfileSettings(user));
- act(() =>
+ const { result } = await renderHook(() => useProfileSettings(user));
+ await act(() =>
result.current.updateState({
image: ' https://example.com/photo.jpg ',
name: ' Updated name ',
@@ -48,7 +48,7 @@ describe('useProfileSettings', () => {
test('stores a blank image as null and surfaces auth errors', async () => {
auth.updateUser.mockResolvedValue({ data: null, error: { message: 'Profile update failed' } });
- const { result } = renderHook(() => useProfileSettings(user));
+ const { result } = await renderHook(() => useProfileSettings(user));
await act(() => result.current.save());
diff --git a/apps/web/src/features/settings/hooks/use-session-settings.test.tsx b/apps/web/src/features/settings/hooks/use-session-settings.test.tsx
index 018af6d3..ff2548ce 100644
--- a/apps/web/src/features/settings/hooks/use-session-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-session-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useSessionSettings } from './use-session-settings';
@@ -35,9 +35,9 @@ beforeEach(() => {
describe('useSessionSettings', () => {
test('loads sessions and revokes one by token', async () => {
- const { result } = renderHook(() => useSessionSettings());
+ const { result } = await renderHook(() => useSessionSettings());
- await waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
+ await vi.waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
await act(() => result.current.revoke('other-token'));
expect(auth.revokeSession).toHaveBeenCalledWith({ token: 'other-token' });
@@ -46,9 +46,9 @@ describe('useSessionSettings', () => {
});
test('signs out every session except the current one', async () => {
- const { result } = renderHook(() => useSessionSettings());
+ const { result } = await renderHook(() => useSessionSettings());
- await waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
+ await vi.waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
await act(() => result.current.revokeOthers('current-token'));
expect(auth.revokeOtherSessions).toHaveBeenCalledOnce();
@@ -60,9 +60,9 @@ describe('useSessionSettings', () => {
data: null,
error: { message: 'Sessions unavailable' }
});
- const { result } = renderHook(() => useSessionSettings());
+ const { result } = await renderHook(() => useSessionSettings());
- await waitFor(() => expect(result.current.state.error).toBe('Sessions unavailable'));
+ await vi.waitFor(() => expect(result.current.state.error).toBe('Sessions unavailable'));
await act(() => result.current.refresh());
expect(result.current.state.error).toBeNull();
@@ -71,9 +71,9 @@ describe('useSessionSettings', () => {
test('keeps sessions and reports revoke failures', async () => {
auth.revokeSession.mockResolvedValue({ error: { message: 'Session is already revoked' } });
- const { result } = renderHook(() => useSessionSettings());
+ const { result } = await renderHook(() => useSessionSettings());
- await waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
+ await vi.waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
await act(() => result.current.revoke('other-token'));
expect(result.current.state.sessions).toHaveLength(2);
@@ -83,9 +83,9 @@ describe('useSessionSettings', () => {
test('keeps all sessions when signing out others fails', async () => {
auth.revokeOtherSessions.mockResolvedValue({ error: {} });
- const { result } = renderHook(() => useSessionSettings());
+ const { result } = await renderHook(() => useSessionSettings());
- await waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
+ await vi.waitFor(() => expect(result.current.state.sessions).toHaveLength(2));
await act(() => result.current.revokeOthers('current-token'));
expect(result.current.state.sessions).toHaveLength(2);
diff --git a/apps/web/src/features/settings/hooks/use-two-factor-settings.test.tsx b/apps/web/src/features/settings/hooks/use-two-factor-settings.test.tsx
index 1756325f..2af9e90a 100644
--- a/apps/web/src/features/settings/hooks/use-two-factor-settings.test.tsx
+++ b/apps/web/src/features/settings/hooks/use-two-factor-settings.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTwoFactorSettings } from './use-two-factor-settings';
@@ -32,7 +32,7 @@ afterEach(() => {
describe('useTwoFactorSettings', () => {
test('enrolls and confirms an authenticator', async () => {
- const { result } = renderHook(() => useTwoFactorSettings(false));
+ const { result } = await renderHook(() => useTwoFactorSettings(false));
await act(() => result.current.startEnrollment('current-password'));
expect(auth.enable).toHaveBeenCalledWith({ password: 'current-password' });
@@ -47,7 +47,7 @@ describe('useTwoFactorSettings', () => {
const createObjectURL = vi.fn(() => 'blob:backup-codes');
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() });
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
- const { result } = renderHook(() => useTwoFactorSettings(true));
+ const { result } = await renderHook(() => useTwoFactorSettings(true));
await act(() => result.current.regenerate('current-password'));
@@ -59,7 +59,7 @@ describe('useTwoFactorSettings', () => {
});
test('disables two-factor authentication', async () => {
- const { result } = renderHook(() => useTwoFactorSettings(true));
+ const { result } = await renderHook(() => useTwoFactorSettings(true));
await act(() => result.current.disable('current-password'));
@@ -74,7 +74,7 @@ describe('useTwoFactorSettings', () => {
test('surfaces provider errors', async () => {
auth.enable.mockResolvedValue({ data: null, error: { message: 'Invalid password' } });
- const { result } = renderHook(() => useTwoFactorSettings(false));
+ const { result } = await renderHook(() => useTwoFactorSettings(false));
await act(() => result.current.startEnrollment('wrong-password'));
@@ -83,7 +83,7 @@ describe('useTwoFactorSettings', () => {
test('keeps two-factor disabled when authenticator confirmation fails', async () => {
auth.verifyTotp.mockResolvedValue({ data: null, error: { message: 'Invalid code' } });
- const { result } = renderHook(() => useTwoFactorSettings(false));
+ const { result } = await renderHook(() => useTwoFactorSettings(false));
await act(() => result.current.startEnrollment('current-password'));
await act(() => result.current.confirmEnrollment('invalid-code'));
diff --git a/apps/web/src/features/settings/hooks/use-two-factor-settings.ts b/apps/web/src/features/settings/hooks/use-two-factor-settings.ts
index 7b67562d..149ac43c 100644
--- a/apps/web/src/features/settings/hooks/use-two-factor-settings.ts
+++ b/apps/web/src/features/settings/hooks/use-two-factor-settings.ts
@@ -1,5 +1,6 @@
import { authClient } from '@groam/auth/client';
-import { useState } from 'react';
+import { use, useState } from 'react';
+import { SessionChangeContext } from '@/features/auth/session-change-context';
import { downloadTextFile } from '@/lib/download-text-file';
import { errorMessage } from '@/lib/errors';
import type { TwoFactorEnrollment } from '@/types/account-security';
@@ -17,6 +18,7 @@ export function useTwoFactorSettings(initiallyEnabled: boolean) {
const [isEnabled, setEnabled] = useState(initiallyEnabled);
const [isPending, setPending] = useState(false);
const [message, setMessage] = useState(null);
+ const changeSession = use(SessionChangeContext);
const run = async (
request: Promise,
@@ -46,11 +48,11 @@ export function useTwoFactorSettings(initiallyEnabled: boolean) {
};
const confirmEnrollment = async (code: string) => {
+ // Confirming enrollment replaces the session.
const result = await run(
- authClient.twoFactor.verifyTotp({
- code: code.trim(),
- trustDevice: true
- }),
+ changeSession(() =>
+ authClient.twoFactor.verifyTotp({ code: code.trim(), trustDevice: true })
+ ),
'Invalid authenticator code'
);
if (result) {
@@ -71,8 +73,9 @@ export function useTwoFactorSettings(initiallyEnabled: boolean) {
};
const disable = async (password: string) => {
+ // Disabling replaces the session.
const result = await run(
- authClient.twoFactor.disable({ password }),
+ changeSession(() => authClient.twoFactor.disable({ password })),
'Unable to disable two-factor authentication'
);
if (result) {
diff --git a/apps/web/src/features/settings/settings-panels/account-recovery-settings.test.tsx b/apps/web/src/features/settings/settings-panels/account-recovery-settings.test.tsx
index 79dc2497..8ff57f56 100644
--- a/apps/web/src/features/settings/settings-panels/account-recovery-settings.test.tsx
+++ b/apps/web/src/features/settings/settings-panels/account-recovery-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { AccountRecoverySettings } from './account-recovery-settings';
const recovery = vi.hoisted(() => ({
@@ -20,26 +21,22 @@ beforeEach(() => {
recovery.message = null;
});
-afterEach(cleanup);
+test('requires the current password before generating recovery codes', async () => {
+ await render();
+ const generate = page.getByRole('button', { name: 'Generate and download' });
-test('requires the current password before generating recovery codes', () => {
- render();
- const generate = screen.getByRole('button', { name: 'Generate and download' });
+ await expect.element(generate).toBeDisabled();
+ await page.getByLabelText('Current password').fill('current-password');
+ await expect.element(generate).toBeEnabled();
- expect(generate.hasAttribute('disabled')).toBe(true);
- fireEvent.change(screen.getByLabelText('Current password'), {
- target: { value: 'current-password' }
- });
- expect(generate.hasAttribute('disabled')).toBe(false);
-
- fireEvent.click(generate);
+ await generate.click();
expect(recovery.generate).toHaveBeenCalledWith('current-password');
});
-test('renders recovery errors as an accessible alert', () => {
+test('renders recovery errors as an accessible alert', async () => {
recovery.error = 'Invalid password';
- render();
+ await render();
- expect(screen.getByRole('alert').textContent).toBe('Invalid password');
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Invalid password');
});
diff --git a/apps/web/src/features/settings/settings-panels/ai-settings.test.tsx b/apps/web/src/features/settings/settings-panels/ai-settings.test.tsx
index 9b124f7f..badc3985 100644
--- a/apps/web/src/features/settings/settings-panels/ai-settings.test.tsx
+++ b/apps/web/src/features/settings/settings-panels/ai-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { testIds } from '@/lib/test-ids';
import { AiSettings } from './ai-settings';
@@ -40,7 +41,8 @@ const ai = vi.hoisted(() => ({
updateDraft: vi.fn()
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to, ...props }: { children: import('react').ReactNode; to: string }) => (
{children}
@@ -52,7 +54,6 @@ vi.mock('@/features/settings/hooks/use-ai-settings', () => ({
}));
afterEach(() => {
- cleanup();
ai.canManageOrganization = true;
ai.configured = false;
ai.environmentConfigured = false;
@@ -75,39 +76,50 @@ afterEach(() => {
});
describe('AiSettings', () => {
- test('explains that saved keys are personal and take priority', () => {
- render();
- expect(screen.getByText(/personal key follows your account/i)).toBeTruthy();
- expect(screen.queryByTestId(testIds.settingsAiEnvironmentNotice)).toBeNull();
+ test('links to the background activity page', async () => {
+ await render();
+ await expect
+ .element(page.getByRole('link', { name: 'Background activity' }))
+ .toHaveAttribute('href', '/agents');
});
- test('shows an organization notice when a shared key is available', () => {
+ test('explains that saved keys are personal and take priority', async () => {
+ await render();
+ await expect.element(page.getByText(/personal key follows your account/i)).toBeInTheDocument();
+ await expect
+ .element(page.getByTestId(testIds.settingsAiEnvironmentNotice))
+ .not.toBeInTheDocument();
+ });
+
+ test('shows an organization notice when a shared key is available', async () => {
ai.organizationConfigured = true;
ai.effectiveProvider = 'openai';
ai.effectiveSource = 'organization';
- render();
- expect(screen.getByTestId(testIds.settingsAiEnvironmentNotice).textContent).toMatch(
+ await render();
+ expect(page.getByTestId(testIds.settingsAiEnvironmentNotice).element().textContent).toMatch(
/Organization connection available/i
);
});
- test('clearly labels the shared organization connection', () => {
+ test('clearly labels the shared organization connection', async () => {
ai.configured = true;
ai.target = 'organization';
- render();
- expect(screen.getByText('Organization connection')).toBeTruthy();
- expect(screen.getByText('Organization AI provider')).toBeTruthy();
- expect(screen.getByText(/shared organization connection is ready/i)).toBeTruthy();
+ await render();
+ await expect.element(page.getByText('Organization connection')).toBeInTheDocument();
+ await expect.element(page.getByText('Organization AI provider')).toBeInTheDocument();
+ await expect
+ .element(page.getByText(/shared organization connection is ready/i))
+ .toBeInTheDocument();
});
- test('offers one-click OpenRouter connection when OpenRouter is selected', () => {
+ test('offers one-click OpenRouter connection when OpenRouter is selected', async () => {
ai.provider = 'openrouter' as typeof ai.provider;
ai.providerConnection = { connect: vi.fn(), label: 'Connect OpenRouter' };
- render();
- expect(screen.getByTestId(testIds.settingsAiConnectProvider)).toBeTruthy();
+ await render();
+ await expect.element(page.getByTestId(testIds.settingsAiConnectProvider)).toBeInTheDocument();
});
- test('asks for a base URL and hides web search for local hosts', () => {
+ test('asks for a base URL and hides web search for local hosts', async () => {
ai.preset = {
...ai.preset,
baseURL: 'http://127.0.0.1:11434/v1',
@@ -118,8 +130,8 @@ describe('AiSettings', () => {
};
ai.provider = 'compatible' as typeof ai.provider;
ai.baseUrl = 'http://127.0.0.1:11434/v1';
- render();
- expect(screen.getByTestId(testIds.settingsAiBaseUrl)).toBeTruthy();
- expect(screen.getByText(/Web search is off for this host/i)).toBeTruthy();
+ await render();
+ await expect.element(page.getByTestId(testIds.settingsAiBaseUrl)).toBeInTheDocument();
+ await expect.element(page.getByText(/Web search is off for this host/i)).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/settings/settings-panels/appearance-settings.test.tsx b/apps/web/src/features/settings/settings-panels/appearance-settings.test.tsx
new file mode 100644
index 00000000..abcdb1fb
--- /dev/null
+++ b/apps/web/src/features/settings/settings-panels/appearance-settings.test.tsx
@@ -0,0 +1,74 @@
+import { UiThemeProvider } from '@groam/ui/components/theme-provider';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
+import { afterEach, beforeEach, describe, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { AppearanceSettings } from './appearance-settings';
+
+const prefersDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
+const isDark = () => document.documentElement.classList.contains('dark');
+
+beforeEach(() => {
+ localStorage.removeItem('groam-theme');
+});
+
+afterEach(() => {
+ localStorage.removeItem('groam-theme');
+ document.documentElement.classList.remove('dark', 'light');
+});
+
+async function renderAppearance() {
+ return render(
+
+
+
+ );
+}
+
+describe('color theme', () => {
+ test('follows the device setting until a theme is chosen', async () => {
+ await renderAppearance();
+ await expect.poll(isDark).toBe(prefersDark());
+ expect(localStorage.getItem('groam-theme')).toBeNull();
+ });
+
+ test('applies and remembers an explicit light or dark choice', async () => {
+ await renderAppearance();
+ await page.getByRole('button', { name: 'Dark' }).click();
+ await expect.poll(isDark).toBe(true);
+ expect(localStorage.getItem('groam-theme')).toBe('dark');
+
+ await page.getByRole('button', { name: 'Light' }).click();
+ await expect.poll(isDark).toBe(false);
+ expect(localStorage.getItem('groam-theme')).toBe('light');
+ });
+
+ test('returns to the device setting when System is selected again', async () => {
+ await renderAppearance();
+ await page.getByRole('button', { name: 'Dark' }).click();
+ await expect.poll(isDark).toBe(true);
+ await page.getByRole('button', { name: 'System' }).click();
+ await expect.poll(isDark).toBe(prefersDark());
+ expect(localStorage.getItem('groam-theme')).toBe('system');
+ });
+
+ test('reports the saved choice as the pressed one', async () => {
+ localStorage.setItem('groam-theme', 'dark');
+ await renderAppearance();
+ await expect
+ .element(page.getByRole('button', { name: 'Dark' }))
+ .toHaveAttribute('aria-pressed', 'true');
+ await expect
+ .element(page.getByRole('button', { name: 'System' }))
+ .toHaveAttribute('aria-pressed', 'false');
+ });
+
+ test('fits a phone without scrolling sideways', async () => {
+ await useViewport(390, 844);
+ await renderAppearance();
+ await expect.element(page.getByRole('button', { name: 'System' })).toBeVisible();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
+ });
+});
diff --git a/apps/web/src/features/settings/settings-panels/data-settings.test.tsx b/apps/web/src/features/settings/settings-panels/data-settings.test.tsx
index 7be4ddc4..0e03ed53 100644
--- a/apps/web/src/features/settings/settings-panels/data-settings.test.tsx
+++ b/apps/web/src/features/settings/settings-panels/data-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { DataSettings } from './data-settings';
vi.mock('@/features/settings/hooks/use-desktop-data', () => ({
@@ -8,14 +9,12 @@ vi.mock('@/features/settings/hooks/use-desktop-data', () => ({
readGroamDataDir: vi.fn(async () => '/tmp/groam-data')
}));
-afterEach(() => cleanup());
-
describe('DataSettings', () => {
test('shows the local data folder and export actions', async () => {
- render();
- expect(await screen.findByTestId('settings-data-path')).toBeTruthy();
- expect(screen.getByTestId('settings-data-export')).toBeTruthy();
- expect(screen.getByTestId('settings-data-import')).toBeTruthy();
- expect(await screen.findByText('/tmp/groam-data')).toBeTruthy();
+ await render();
+ await expect.element(page.getByTestId('settings-data-path')).toBeInTheDocument();
+ await expect.element(page.getByTestId('settings-data-export')).toBeInTheDocument();
+ await expect.element(page.getByTestId('settings-data-import')).toBeInTheDocument();
+ await expect.element(page.getByText('/tmp/groam-data')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/settings/settings-panels/profile-settings.test.tsx b/apps/web/src/features/settings/settings-panels/profile-settings.test.tsx
index 6e73be5c..79f90d4f 100644
--- a/apps/web/src/features/settings/settings-panels/profile-settings.test.tsx
+++ b/apps/web/src/features/settings/settings-panels/profile-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { Session } from '@/features/workspace/workspace-shell/workspace-state';
import { ProfileSettings } from './profile-settings';
@@ -40,34 +41,32 @@ beforeEach(() => {
};
});
-afterEach(cleanup);
-
describe('ProfileSettings', () => {
- test('renders editable profile and username fields', () => {
- render();
+ test('renders editable profile and username fields', async () => {
+ await render();
- expect(screen.getByLabelText('Display name')).toBeTruthy();
- expect(screen.getByLabelText('Username')).toBeTruthy();
- expect(screen.getByLabelText('Profile photo URL')).toBeTruthy();
- expect(screen.getByDisplayValue('traveler')).toBeTruthy();
+ await expect.element(page.getByLabelText('Display name')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Username')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Profile photo URL')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Username')).toHaveValue('traveler');
});
test('submits the profile form through the settings hook', async () => {
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Save profile' }));
+ await page.getByRole('button', { name: 'Save profile' }).click();
- await waitFor(() => expect(profile.save).toHaveBeenCalledOnce());
+ await vi.waitFor(() => expect(profile.save).toHaveBeenCalledOnce());
});
- test('shows validation feedback from the settings hook', () => {
+ test('shows validation feedback from the settings hook', async () => {
profile.state = {
...profile.state,
error: 'Unable to update profile'
};
- render();
+ await render();
- expect(screen.getByRole('alert').textContent).toBe('Unable to update profile');
+ await expect.element(page.getByRole('alert')).toHaveTextContent('Unable to update profile');
});
});
diff --git a/apps/web/src/features/settings/settings-panels/session-settings.test.tsx b/apps/web/src/features/settings/settings-panels/session-settings.test.tsx
index 6cce4ff8..65dc75e1 100644
--- a/apps/web/src/features/settings/settings-panels/session-settings.test.tsx
+++ b/apps/web/src/features/settings/settings-panels/session-settings.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { SessionSettings } from './session-settings';
const sessions = vi.hoisted(() => ({
@@ -53,31 +54,29 @@ beforeEach(() => {
};
});
-afterEach(cleanup);
-
describe('SessionSettings', () => {
- test('lists active sessions and marks the current browser', () => {
- render();
+ test('lists active sessions and marks the current browser', async () => {
+ await render();
- expect(screen.getByText('2 active sessions')).toBeTruthy();
- expect(screen.getByText('Current')).toBeTruthy();
- expect(screen.getByText(/Chrome on macOS/u)).toBeTruthy();
- expect(screen.getByText(/Safari on iOS/u)).toBeTruthy();
+ await expect.element(page.getByText('2 active sessions')).toBeInTheDocument();
+ await expect.element(page.getByText('Current')).toBeInTheDocument();
+ await expect.element(page.getByText(/Chrome on macOS/u)).toBeInTheDocument();
+ await expect.element(page.getByText(/Safari on iOS/u)).toBeInTheDocument();
});
test('revokes another session from the panel', async () => {
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: /Sign out Safari on iOS/u }));
+ await page.getByRole('button', { name: /Sign out Safari on iOS/u }).click();
- await waitFor(() => expect(sessions.revoke).toHaveBeenCalledWith('other-token'));
+ await vi.waitFor(() => expect(sessions.revoke).toHaveBeenCalledWith('other-token'));
});
test('signs out every other session at once', async () => {
- render();
+ await render();
- fireEvent.click(screen.getByRole('button', { name: 'Sign out others' }));
+ await page.getByRole('button', { name: 'Sign out others' }).click();
- await waitFor(() => expect(sessions.revokeOthers).toHaveBeenCalledWith('current-token'));
+ await vi.waitFor(() => expect(sessions.revokeOthers).toHaveBeenCalledWith('current-token'));
});
});
diff --git a/apps/web/src/features/settings/settings-shell/settings-panel.test.tsx b/apps/web/src/features/settings/settings-shell/settings-panel.test.tsx
index 0ca40009..71231dc2 100644
--- a/apps/web/src/features/settings/settings-shell/settings-panel.test.tsx
+++ b/apps/web/src/features/settings/settings-shell/settings-panel.test.tsx
@@ -1,6 +1,7 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { MonitorCog } from 'lucide-react';
-import { afterEach, describe, expect, test } from 'vite-plus/test';
+import { describe, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import {
SettingsAside,
SettingsFooter,
@@ -10,11 +11,9 @@ import {
SettingsStack
} from './settings-panel';
-afterEach(cleanup);
-
describe('settings layout primitives', () => {
- test('SettingsStack groups panels with vertical spacing', () => {
- const { container } = render(
+ test('SettingsStack groups panels with vertical spacing', async () => {
+ const { container } = await render(
First panel
Second panel
@@ -22,12 +21,12 @@ describe('settings layout primitives', () => {
);
expect(container.firstElementChild?.className).toContain('space-y-5');
- expect(screen.getByText('First panel')).toBeTruthy();
- expect(screen.getByText('Second panel')).toBeTruthy();
+ await expect.element(page.getByText('First panel')).toBeInTheDocument();
+ await expect.element(page.getByText('Second panel')).toBeInTheDocument();
});
- test('SettingsPanelHeading renders title, description, and optional action', () => {
- render(
+ test('SettingsPanelHeading renders title, description, and optional action', async () => {
+ await render(
Refresh}
description="Panel details"
@@ -36,25 +35,25 @@ describe('settings layout primitives', () => {
/>
);
- expect(screen.getByRole('heading', { name: 'Panel title' })).toBeTruthy();
- expect(screen.getByText('Panel details')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy();
+ await expect.element(page.getByRole('heading', { name: 'Panel title' })).toBeInTheDocument();
+ await expect.element(page.getByText('Panel details')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
});
- test('SettingsFooter renders action content', () => {
- render(
+ test('SettingsFooter renders action content', async () => {
+ await render(
Footer note
);
- expect(screen.getByText('Footer note')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy();
+ await expect.element(page.getByText('Footer note')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
- test('SettingsAside renders informational copy', () => {
- render(
+ test('SettingsAside renders informational copy', async () => {
+ await render(
{
/>
);
- expect(screen.getByRole('heading', { name: 'Saved locally' })).toBeTruthy();
- expect(screen.getByText('Theme changes apply on this device only.')).toBeTruthy();
+ await expect.element(page.getByRole('heading', { name: 'Saved locally' })).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Theme changes apply on this device only.'))
+ .toBeInTheDocument();
});
- test('SettingsSplitLayout renders main content and aside column', () => {
- render(
+ test('SettingsSplitLayout renders main content and aside column', async () => {
+ await render(
Aside content
}>
Main content
@@ -75,7 +76,7 @@ describe('settings layout primitives', () => {
);
- expect(screen.getByText('Main content')).toBeTruthy();
- expect(screen.getByText('Aside content')).toBeTruthy();
+ await expect.element(page.getByText('Main content')).toBeInTheDocument();
+ await expect.element(page.getByText('Aside content')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/settings/settings-shell/settings-view.test.tsx b/apps/web/src/features/settings/settings-shell/settings-view.test.tsx
index 481d3467..f1d54973 100644
--- a/apps/web/src/features/settings/settings-shell/settings-view.test.tsx
+++ b/apps/web/src/features/settings/settings-shell/settings-view.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { SettingsView } from './settings-view';
const testState = vi.hoisted(() => ({
@@ -69,7 +70,6 @@ vi.mock('@/features/settings/settings-panels/security-settings', () => ({
}));
afterEach(() => {
- cleanup();
testState.aiMounts = 0;
testState.hideAi = false;
testState.environmentConfigured = false;
@@ -82,53 +82,55 @@ afterEach(() => {
});
describe('SettingsView', () => {
- test('keeps people management on the group settings section', () => {
- render();
- expect(screen.getByTestId('invite-member')).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'New group' })).toBeNull();
- expect(screen.getByText('Group settings')).toBeTruthy();
- expect(screen.getByText('People settings')).toBeTruthy();
+ test('keeps people management on the group settings section', async () => {
+ await render();
+ await expect.element(page.getByTestId('invite-member')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'New group' })).not.toBeInTheDocument();
+ await expect.element(page.getByText('Group settings')).toBeInTheDocument();
+ await expect.element(page.getByText('People settings')).toBeInTheDocument();
});
- test('shows the AI section for every shell', () => {
- render();
- expect(screen.getByTestId('settings-section-ai')).toBeTruthy();
- expect(screen.getByText('AI settings 1')).toBeTruthy();
+ test('shows the AI section for every shell', async () => {
+ await render();
+ await expect.element(page.getByTestId('settings-section-ai')).toBeInTheDocument();
+ await expect.element(page.getByText('AI settings 1')).toBeInTheDocument();
});
- test('hides hosted AI settings and redirects a direct AI section', () => {
+ test('hides hosted AI settings and redirects a direct AI section', async () => {
testState.hideAi = true;
testState.environmentConfigured = true;
const onSectionChange = vi.fn();
- render();
- expect(screen.queryByTestId('settings-section-ai')).toBeNull();
- expect(screen.queryByText(/AI settings/)).toBeNull();
+ await render();
+ await expect.element(page.getByTestId('settings-section-ai')).not.toBeInTheDocument();
+ await expect.element(page.getByText(/AI settings/)).not.toBeInTheDocument();
expect(onSectionChange).toHaveBeenCalledWith('profile');
});
- test('keeps the AI tab hidden while showing progress for a direct AI section', () => {
+ test('keeps the AI tab hidden while showing progress for a direct AI section', async () => {
testState.hideAi = true;
testState.isAiAvailabilityLoading = true;
const onSectionChange = vi.fn();
- render();
- expect(screen.queryByTestId('settings-section-ai')).toBeNull();
- expect(screen.getByRole('status', { name: 'Loading AI settings…' })).toBeTruthy();
- expect(screen.queryByText(/AI settings 1/)).toBeNull();
+ await render();
+ await expect.element(page.getByTestId('settings-section-ai')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('status', { name: 'Loading AI settings…' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByText(/AI settings 1/)).not.toBeInTheDocument();
expect(onSectionChange).not.toHaveBeenCalled();
});
- test('remounts AI settings when the active group changes', () => {
+ test('remounts AI settings when the active group changes', async () => {
const onSectionChange = vi.fn();
- const { rerender } = render(
+ const { rerender } = await render(
);
- expect(screen.getByText('AI settings 1')).toBeTruthy();
+ await expect.element(page.getByText('AI settings 1')).toBeInTheDocument();
testState.workspace = {
...testState.workspace,
activeOrganization: { id: 'org-other', name: 'Other Group' }
};
- rerender();
- expect(screen.getByText('AI settings 2')).toBeTruthy();
+ await rerender();
+ await expect.element(page.getByText('AI settings 2')).toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/trips/expect-route-stop-photos.ts b/apps/web/src/features/trips/expect-route-stop-photos.ts
index 598b270f..1b3622d7 100644
--- a/apps/web/src/features/trips/expect-route-stop-photos.ts
+++ b/apps/web/src/features/trips/expect-route-stop-photos.ts
@@ -1,13 +1,14 @@
-import { screen } from '@testing-library/react';
import { expect } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { testIds } from '@/lib/test-ids';
/** Shared assertions for route-stop photo markers in trip overview / itinerary tests. */
-export function expectRouteStopPhotos(firstImageSrc: string) {
- const photos = screen.getAllByTestId(testIds.tripRouteStopPhoto);
- expect(photos).toHaveLength(2);
- expect(photos[0]?.querySelector('img')?.getAttribute('src')).toBe(firstImageSrc);
- expect(photos[0]?.textContent).toContain('1');
- expect(photos[1]?.textContent).toContain('2');
- expect(photos[1]?.querySelector('img')).toBeNull();
+export async function expectRouteStopPhotos(firstImageSrc: string) {
+ const photos = page.getByTestId(testIds.tripRouteStopPhoto);
+ await expect.poll(() => photos.elements()).toHaveLength(2);
+ const [first, second] = photos.elements();
+ expect(first?.querySelector('img')?.getAttribute('src')).toBe(firstImageSrc);
+ expect(first?.textContent).toContain('1');
+ expect(second?.textContent).toContain('2');
+ expect(second?.querySelector('img')).toBeNull();
}
diff --git a/apps/web/src/features/trips/hooks/use-create-trip-idea-dialog.test.tsx b/apps/web/src/features/trips/hooks/use-create-trip-idea-dialog.test.tsx
index e2f1f726..a74c805e 100644
--- a/apps/web/src/features/trips/hooks/use-create-trip-idea-dialog.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-create-trip-idea-dialog.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test, vi } from 'vite-plus/test';
import { useCreateTripIdeaDialog } from './use-create-trip-idea-dialog';
@@ -7,12 +7,14 @@ describe('useCreateTripIdeaDialog', () => {
test('opens with optional intent and creates then closes', async () => {
const createVersion = vi.fn().mockResolvedValue({ proposalId: 'proposal-1' });
const onCreated = vi.fn();
- const { result } = renderHook(() => useCreateTripIdeaDialog({ createVersion, onCreated }));
+ const { result } = await renderHook(() =>
+ useCreateTripIdeaDialog({ createVersion, onCreated })
+ );
expect(result.current.dialogOpen).toBe(false);
expect(result.current.isCreating).toBe(false);
- act(() => {
+ await act(() => {
result.current.openDialog({ addDestination: true, section: 'itinerary' });
});
expect(result.current.dialogOpen).toBe(true);
@@ -33,9 +35,11 @@ describe('useCreateTripIdeaDialog', () => {
test('keeps the dialog open when create returns null', async () => {
const createVersion = vi.fn().mockResolvedValue(null);
const onCreated = vi.fn();
- const { result } = renderHook(() => useCreateTripIdeaDialog({ createVersion, onCreated }));
+ const { result } = await renderHook(() =>
+ useCreateTripIdeaDialog({ createVersion, onCreated })
+ );
- act(() => {
+ await act(() => {
result.current.openDialog();
});
await act(async () => {
@@ -49,7 +53,9 @@ describe('useCreateTripIdeaDialog', () => {
test('creates directly for a destination intent without opening the naming dialog', async () => {
const createVersion = vi.fn().mockResolvedValue({ proposalId: 'proposal-2' });
const onCreated = vi.fn();
- const { result } = renderHook(() => useCreateTripIdeaDialog({ createVersion, onCreated }));
+ const { result } = await renderHook(() =>
+ useCreateTripIdeaDialog({ createVersion, onCreated })
+ );
await act(async () => {
await result.current.createForIntent({ addDestination: true, section: 'itinerary' });
diff --git a/apps/web/src/features/trips/hooks/use-ensure-destination-covers.test.tsx b/apps/web/src/features/trips/hooks/use-ensure-destination-covers.test.tsx
index 5ad7d4f3..d7ac3e79 100644
--- a/apps/web/src/features/trips/hooks/use-ensure-destination-covers.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-ensure-destination-covers.test.tsx
@@ -1,12 +1,15 @@
import type { Id } from '@groam/backend/data-model';
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useEnsureDestinationCovers } from './use-ensure-destination-covers';
import type { TripDetail } from './use-trips';
const convex = vi.hoisted(() => ({ useMutation: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
const tripId = 'trip-1' as Id<'trips'>;
@@ -16,25 +19,25 @@ beforeEach(() => {
});
describe('useEnsureDestinationCovers', () => {
- test('ensures covers when at least one destination has no cover status', () => {
+ test('ensures covers when at least one destination has no cover status', async () => {
const ensure = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValue(ensure);
- renderHook(() =>
+ await renderHook(() =>
useEnsureDestinationCovers(tripId, { destinations: [{ coverStatus: null }] } as TripDetail)
);
expect(ensure).toHaveBeenCalledWith({ tripId });
});
- test('does not request covers while the trip is loading or all covers are resolved', () => {
+ test('does not request covers while the trip is loading or all covers are resolved', async () => {
const ensure = vi.fn();
convex.useMutation.mockReturnValue(ensure);
- const { rerender } = renderHook(({ trip }) => useEnsureDestinationCovers(tripId, trip), {
+ const { rerender } = await renderHook(({ trip }) => useEnsureDestinationCovers(tripId, trip), {
initialProps: { trip: undefined as TripDetail | undefined }
});
- rerender({ trip: { destinations: [{ coverStatus: 'ready' }] } as TripDetail });
+ await rerender({ trip: { destinations: [{ coverStatus: 'ready' }] } as TripDetail });
expect(ensure).not.toHaveBeenCalled();
});
diff --git a/apps/web/src/features/trips/hooks/use-ensure-trip-cover.test.tsx b/apps/web/src/features/trips/hooks/use-ensure-trip-cover.test.tsx
index 3cad5c80..ade95ee3 100644
--- a/apps/web/src/features/trips/hooks/use-ensure-trip-cover.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-ensure-trip-cover.test.tsx
@@ -1,12 +1,15 @@
import type { Id } from '@groam/backend/data-model';
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useEnsureTripCover } from './use-ensure-trip-cover';
import type { TripDetail } from './use-trips';
const convex = vi.hoisted(() => ({ useMutation: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
const tripId = 'trip-1' as Id<'trips'>;
@@ -16,11 +19,11 @@ beforeEach(() => {
});
describe('useEnsureTripCover', () => {
- test('ensures a missing trip cover once the trip has destinations', () => {
+ test('ensures a missing trip cover once the trip has destinations', async () => {
const ensure = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValue(ensure);
- renderHook(() =>
+ await renderHook(() =>
useEnsureTripCover(tripId, {
coverStatus: null,
destinations: [{ id: 'destination-1' }]
@@ -30,16 +33,16 @@ describe('useEnsureTripCover', () => {
expect(ensure).toHaveBeenCalledWith({ tripId });
});
- test('does not ensure a cover for an empty trip or an already-resolved cover', () => {
+ test('does not ensure a cover for an empty trip or an already-resolved cover', async () => {
const ensure = vi.fn();
convex.useMutation.mockReturnValue(ensure);
- const { rerender } = renderHook(({ trip }) => useEnsureTripCover(tripId, trip), {
+ const { rerender } = await renderHook(({ trip }) => useEnsureTripCover(tripId, trip), {
initialProps: {
trip: { coverStatus: null, destinations: [] } as unknown as TripDetail
}
});
- rerender({
+ await rerender({
trip: {
coverStatus: 'ready',
destinations: [{ id: 'destination-1' }]
diff --git a/apps/web/src/features/trips/hooks/use-idea-rebase-reconciliation.test.ts b/apps/web/src/features/trips/hooks/use-idea-rebase-reconciliation.test.ts
index 6490ea32..39f6f8c2 100644
--- a/apps/web/src/features/trips/hooks/use-idea-rebase-reconciliation.test.ts
+++ b/apps/web/src/features/trips/hooks/use-idea-rebase-reconciliation.test.ts
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { expect, test } from 'vite-plus/test';
import {
rebaseBlockedReason,
@@ -38,14 +38,14 @@ const conflicts = [
}
];
-test('explains rebase as a working-copy update for the author or group admin', () => {
+test('explains rebase as a working-copy update for the author or group admin', async () => {
expect(rebaseBlockedReason(true)).toBeNull();
expect(rebaseBlockedReason(false)).toBe(
'Only the idea author or a group organizer can update this idea.'
);
});
-test('formats progress and explanations for one conflict at a time', () => {
+test('formats progress and explanations for one conflict at a time', async () => {
expect(rebaseProgressLabel(0, 0)).toBe('');
expect(rebaseProgressLabel(1, 5)).toBe('Change 2 of 5');
expect(rebaseExplanation(conflicts[0]!)).toBe('The shared trip and this idea both changed name.');
@@ -58,10 +58,10 @@ test('formats progress and explanations for one conflict at a time', () => {
).toBe('The shared trip changed name. This idea changed duration.');
});
-test('walks first, middle, and last steps and records keep-shared vs keep-idea', () => {
- const { result } = renderHook(() => useIdeaRebaseReconciliation());
+test('walks first, middle, and last steps and records keep-shared vs keep-idea', async () => {
+ const { result } = await renderHook(() => useIdeaRebaseReconciliation());
- act(() => {
+ await act(() => {
result.current.start(conflicts);
});
expect(result.current.open).toBe(true);
@@ -71,7 +71,7 @@ test('walks first, middle, and last steps and records keep-shared vs keep-idea',
expect(result.current.isLast).toBe(false);
let choice = { completed: false, resolutions: [] as ReturnType };
- act(() => {
+ await act(() => {
choice = result.current.choose('current');
});
expect(choice.completed).toBe(false);
@@ -80,24 +80,24 @@ test('walks first, middle, and last steps and records keep-shared vs keep-idea',
expect(result.current.isFirst).toBe(false);
expect(result.current.isLast).toBe(false);
- act(() => {
+ await act(() => {
result.current.choose('proposed');
});
expect(result.current.progressLabel).toBe('Change 3 of 3');
expect(result.current.isLast).toBe(true);
- act(() => {
+ await act(() => {
result.current.back();
});
expect(result.current.progressLabel).toBe('Change 2 of 3');
expect(result.current.currentChoice).toBe('proposed');
- act(() => {
+ await act(() => {
choice = result.current.choose('proposed');
});
expect(choice.completed).toBe(false);
- act(() => {
+ await act(() => {
choice = result.current.choose('current');
});
expect(choice).toEqual({
@@ -110,8 +110,8 @@ test('walks first, middle, and last steps and records keep-shared vs keep-idea',
});
});
-test('does not open a wizard when there is nothing to reconcile', () => {
- const { result } = renderHook(() => useIdeaRebaseReconciliation());
+test('does not open a wizard when there is nothing to reconcile', async () => {
+ const { result } = await renderHook(() => useIdeaRebaseReconciliation());
expect(result.current.open).toBe(false);
expect(result.current.total).toBe(0);
expect(result.current.current).toBeUndefined();
diff --git a/apps/web/src/features/trips/hooks/use-itinerary-details-resolve.test.tsx b/apps/web/src/features/trips/hooks/use-itinerary-details-resolve.test.tsx
index 61877834..d04df5b1 100644
--- a/apps/web/src/features/trips/hooks/use-itinerary-details-resolve.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-itinerary-details-resolve.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { ItineraryChange } from './itinerary-proposal-changes';
import { useItineraryDetailsResolve } from './use-itinerary-details-resolve';
@@ -15,7 +15,10 @@ const ideaContext = vi.hoisted(() => ({ useOptionalIdeaContext: vi.fn() }));
const pending = vi.hoisted(() => ({ run: vi.fn() }));
const sheet = vi.hoisted(() => ({ open: false, setOpen: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
vi.mock('@/features/ideas/hooks/use-idea-context', () => ({
useOptionalIdeaContext: () => ideaContext.useOptionalIdeaContext()
@@ -84,7 +87,7 @@ describe('useItineraryDetailsResolve', () => {
test('builds choices from loaded data and rebases the selected details', async () => {
const rebase = vi.fn().mockResolvedValue({ kind: 'applied' });
convex.useAction.mockReturnValue(rebase);
- const { result } = renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
+ const { result } = await renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
expect(result.current).toMatchObject({
canResolve: true,
@@ -111,7 +114,7 @@ describe('useItineraryDetailsResolve', () => {
test('does not submit incomplete choices and reports remaining rebase conflicts', async () => {
const rebase = vi.fn().mockResolvedValue({ kind: 'conflicts' });
convex.useAction.mockReturnValue(rebase);
- const { result } = renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
+ const { result } = await renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
await act(async () => {
expect(await result.current.applyChoices({})).toBe(false);
@@ -142,7 +145,7 @@ describe('useItineraryDetailsResolve', () => {
.mockReturnValueOnce([{ mediaId: 'mine-file', name: 'My file' }]);
convex.useAction.mockReturnValue(rebase);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useItineraryDetailsResolve({ change: attachmentChange, trip: trip() })
);
@@ -173,7 +176,7 @@ describe('useItineraryDetailsResolve', () => {
arguments_ === 'skip' ? undefined : undefined
);
convex.useAction.mockReturnValue(vi.fn());
- const { result } = renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
+ const { result } = await renderHook(() => useItineraryDetailsResolve({ change, trip: trip() }));
expect(result.current).toMatchObject({
canResolve: false,
@@ -192,7 +195,7 @@ describe('useItineraryDetailsResolve', () => {
test('reports rebase errors and treats a cancelled pending action as unresolved', async () => {
const rebase = vi.fn().mockRejectedValue(new Error('Server Error'));
convex.useAction.mockReturnValue(rebase);
- const { result, rerender } = renderHook(() =>
+ const { result, rerender } = await renderHook(() =>
useItineraryDetailsResolve({ change, trip: trip() })
);
@@ -202,7 +205,7 @@ describe('useItineraryDetailsResolve', () => {
expect(notifications.error).toHaveBeenCalledWith('Unable to apply the selected details');
pending.run.mockResolvedValueOnce(null);
- rerender();
+ await rerender();
await act(async () => {
expect(await result.current.applyChoices({ name: 'mine' })).toBe(false);
});
diff --git a/apps/web/src/features/trips/hooks/use-itinerary-proposal-changes.test.tsx b/apps/web/src/features/trips/hooks/use-itinerary-proposal-changes.test.tsx
index 30eb6a0d..271bbe67 100644
--- a/apps/web/src/features/trips/hooks/use-itinerary-proposal-changes.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-itinerary-proposal-changes.test.tsx
@@ -1,5 +1,6 @@
-import { render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { ItineraryChange } from './itinerary-proposal-changes';
import {
ItineraryProposalChangesProvider,
@@ -41,29 +42,29 @@ beforeEach(() => {
versions.useTripProposalDetail.mockReturnValue(undefined);
});
-test('provides context proposal changes by their stable key', () => {
+test('provides context proposal changes by their stable key', async () => {
ideaContext.useOptionalIdeaContext.mockReturnValue({
proposal: { changes: [change], id: 'proposal-1' }
});
- render(
+ await render(
);
- expect(screen.getByText('1:destinations/lisbon.json')).toBeTruthy();
+ await expect.element(page.getByText('1:destinations/lisbon.json')).toBeInTheDocument();
expect(versions.useTripProposalDetail).toHaveBeenCalledWith(undefined);
});
-test('provides no changes for a shared trip', () => {
+test('provides no changes for a shared trip', async () => {
ideaContext.useOptionalIdeaContext.mockReturnValue(undefined);
- render(
+ await render(
);
- expect(screen.getByText('0:none')).toBeTruthy();
+ await expect.element(page.getByText('0:none')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/hooks/use-proposal-action-runner.test.tsx b/apps/web/src/features/trips/hooks/use-proposal-action-runner.test.tsx
index 6ceb02f2..68546f65 100644
--- a/apps/web/src/features/trips/hooks/use-proposal-action-runner.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-proposal-action-runner.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { expect, test } from 'vite-plus/test';
import { useProposalActionRunner } from './use-proposal-action-runner';
@@ -8,10 +8,10 @@ test('exposes the action label until the action settles', async () => {
new Promise((resolve) => {
finish = resolve;
});
- const { result } = renderHook(() => useProposalActionRunner());
+ const { result } = await renderHook(() => useProposalActionRunner());
let outcome: Promise | undefined;
- act(() => {
+ await act(() => {
outcome = result.current.run('Applying idea', action);
});
expect(result.current.pendingAction).toBe('Applying idea');
@@ -25,7 +25,7 @@ test('exposes the action label until the action settles', async () => {
});
test('clears the pending label after a rejected action', async () => {
- const { result } = renderHook(() => useProposalActionRunner());
+ const { result } = await renderHook(() => useProposalActionRunner());
await act(async () => {
await expect(
diff --git a/apps/web/src/features/trips/hooks/use-review-comment.test.tsx b/apps/web/src/features/trips/hooks/use-review-comment.test.tsx
index a9a35edc..401ce5e8 100644
--- a/apps/web/src/features/trips/hooks/use-review-comment.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-review-comment.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test, vi } from 'vite-plus/test';
import { useReviewComment } from './use-review-comment';
@@ -6,15 +6,17 @@ describe('useReviewComment', () => {
test('expands resolved threads and clears reply after success', async () => {
const onReply = vi.fn().mockResolvedValue(true);
const onResolve = vi.fn().mockResolvedValue(true);
- const { result } = renderHook(() => useReviewComment({ onReply, onResolve, resolvedAt: 1 }));
+ const { result } = await renderHook(() =>
+ useReviewComment({ onReply, onResolve, resolvedAt: 1 })
+ );
expect(result.current.isExpanded).toBe(false);
- act(() => {
+ await act(() => {
result.current.showResolved();
});
expect(result.current.isExpanded).toBe(true);
- act(() => {
+ await act(() => {
result.current.toggleReplying();
result.current.setReply(' looks good ');
});
@@ -29,7 +31,7 @@ describe('useReviewComment', () => {
});
test('collapses reply UI after resolve', async () => {
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useReviewComment({
onReply: vi.fn(),
onResolve: vi.fn().mockResolvedValue(true),
@@ -37,7 +39,7 @@ describe('useReviewComment', () => {
})
);
- act(() => {
+ await act(() => {
result.current.toggleReplying();
});
await act(async () => {
@@ -48,13 +50,13 @@ describe('useReviewComment', () => {
test('does not submit blank or failed replies and keeps the draft', async () => {
const onReply = vi.fn().mockResolvedValue(false);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useReviewComment({ onReply, onResolve: vi.fn().mockResolvedValue(false), resolvedAt: null })
);
await act(async () => expect(await result.current.submitReply()).toBe(false));
expect(onReply).not.toHaveBeenCalled();
- act(() => result.current.setReply('Needs context'));
+ await act(() => result.current.setReply('Needs context'));
await act(async () => expect(await result.current.submitReply()).toBe(false));
expect(result.current.reply).toBe('Needs context');
await act(async () => expect(await result.current.toggleResolved()).toBe(false));
diff --git a/apps/web/src/features/trips/hooks/use-start-idea-flow.test.tsx b/apps/web/src/features/trips/hooks/use-start-idea-flow.test.tsx
index f2976e98..4114fb76 100644
--- a/apps/web/src/features/trips/hooks/use-start-idea-flow.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-start-idea-flow.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useStartIdeaFlow } from './use-start-idea-flow';
import type { TripDetail } from './use-trips';
@@ -16,7 +16,8 @@ const dialog = vi.hoisted(() => ({
| undefined
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
useNavigate: () => navigate
}));
@@ -63,8 +64,8 @@ beforeEach(() => {
dialog.config = undefined;
});
-test('opens a choice when the viewer already has a draft', () => {
- const { result } = renderHook(() =>
+test('opens a choice when the viewer already has a draft', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [
@@ -79,7 +80,7 @@ test('opens a choice when the viewer already has a draft', () => {
})
);
- act(() => {
+ await act(() => {
result.current.start();
});
@@ -87,8 +88,8 @@ test('opens a choice when the viewer already has a draft', () => {
expect(openDialog).not.toHaveBeenCalled();
});
-test('skips the dialog for the empty-route first destination', () => {
- const { result } = renderHook(() =>
+test('skips the dialog for the empty-route first destination', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [],
@@ -96,7 +97,7 @@ test('skips the dialog for the empty-route first destination', () => {
})
);
- act(() => {
+ await act(() => {
result.current.start({ addDestination: true });
});
@@ -104,8 +105,8 @@ test('skips the dialog for the empty-route first destination', () => {
expect(openDialog).not.toHaveBeenCalled();
});
-test('opens the standard idea dialog with the resolved overview intent', () => {
- const { result } = renderHook(() =>
+test('opens the standard idea dialog with the resolved overview intent', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [],
@@ -113,7 +114,7 @@ test('opens the standard idea dialog with the resolved overview intent', () => {
})
);
- act(() => result.current.start());
+ await act(() => result.current.start());
expect(openDialog).toHaveBeenCalledWith({
addDestination: false,
@@ -123,8 +124,8 @@ test('opens the standard idea dialog with the resolved overview intent', () => {
});
});
-test('navigates directly to an existing draft for the first destination', () => {
- const { result } = renderHook(() =>
+test('navigates directly to an existing draft for the first destination', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [
@@ -139,14 +140,14 @@ test('navigates directly to an existing draft for the first destination', () =>
})
);
- act(() => result.current.start({ addDestination: true }));
+ await act(() => result.current.start({ addDestination: true }));
expect(navigate).toHaveBeenCalled();
expect(createForIntent).not.toHaveBeenCalled();
});
-test('does nothing when the trip cannot accept proposals', () => {
- const { result } = renderHook(() =>
+test('does nothing when the trip cannot accept proposals', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [],
@@ -154,7 +155,7 @@ test('does nothing when the trip cannot accept proposals', () => {
})
);
- act(() => result.current.start({ addDestination: true }));
+ await act(() => result.current.start({ addDestination: true }));
expect(openDialog).not.toHaveBeenCalled();
expect(createForIntent).not.toHaveBeenCalled();
@@ -163,17 +164,17 @@ test('does nothing when the trip cannot accept proposals', () => {
test('creates through the guarded dialog capability and opens the result', async () => {
const proposalId = 'proposal-new' as Id<'tripProposals'>;
const createVersion = vi.fn().mockResolvedValue({ proposalId });
- renderHook(() => useStartIdeaFlow({ createVersion, proposals: [], trip: trip() }));
+ await renderHook(() => useStartIdeaFlow({ createVersion, proposals: [], trip: trip() }));
await expect(dialog.config?.createVersion('Coastal route')).resolves.toEqual({ proposalId });
- act(() => dialog.config?.onCreated(proposalId, { section: 'overview' }));
+ await act(() => dialog.config?.onCreated(proposalId, { section: 'overview' }));
expect(createVersion).toHaveBeenCalledWith('Coastal route');
expect(navigate).toHaveBeenCalled();
});
-test('continues or starts another idea from the existing-draft choice', () => {
- const { result } = renderHook(() =>
+test('continues or starts another idea from the existing-draft choice', async () => {
+ const { result } = await renderHook(() =>
useStartIdeaFlow({
createVersion: vi.fn(),
proposals: [
@@ -188,11 +189,11 @@ test('continues or starts another idea from the existing-draft choice', () => {
})
);
- act(() => result.current.start({ titleHint: 'A new path' }));
- act(() => result.current.continueDraft());
+ await act(() => result.current.start({ titleHint: 'A new path' }));
+ await act(() => result.current.continueDraft());
expect(navigate).toHaveBeenCalled();
- act(() => result.current.start({ titleHint: 'Another' }));
- act(() => result.current.startAnotherIdea());
+ await act(() => result.current.start({ titleHint: 'Another' }));
+ await act(() => result.current.startAnotherIdea());
expect(openDialog).toHaveBeenCalledWith(expect.objectContaining({ titleHint: 'Another' }));
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-activity-editor.test.tsx b/apps/web/src/features/trips/hooks/use-trip-activity-editor.test.tsx
index 2b0cbe5e..1cbb06d6 100644
--- a/apps/web/src/features/trips/hooks/use-trip-activity-editor.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-activity-editor.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { TripDestinationWithActivities } from './use-trip-activity-editor';
import { useTripActivityEditor } from './use-trip-activity-editor';
@@ -61,9 +61,9 @@ beforeEach(() => {
describe('useTripActivityEditor', () => {
test('creates an activity with a trimmed exact address', async () => {
const { addActivity, props } = setup();
- const { result } = renderHook(() => useTripActivityEditor(props));
+ const { result } = await renderHook(() => useTripActivityEditor(props));
- act(() => {
+ await act(() => {
result.current.openNew();
result.current.patch({
address: ' Rua da Bica 18, Lisbon, Portugal ',
@@ -123,9 +123,9 @@ describe('useTripActivityEditor', () => {
const { props, updateActivity } = setup({
destination: destination({ activities: [activity] })
});
- const { result } = renderHook(() => useTripActivityEditor(props));
+ const { result } = await renderHook(() => useTripActivityEditor(props));
- act(() => result.current.edit(activity));
+ await act(() => result.current.edit(activity));
expect(result.current).toMatchObject({
address: 'Praça do Comércio, Lisbon',
dayNumber: '2',
@@ -135,7 +135,7 @@ describe('useTripActivityEditor', () => {
notes: 'North entrance',
title: 'Walking tour'
});
- act(() => result.current.patch({ address: 'Updated exact entrance' }));
+ await act(() => result.current.patch({ address: 'Updated exact entrance' }));
await act(() => result.current.submit());
expect(updateActivity).toHaveBeenCalledWith(
@@ -144,16 +144,16 @@ describe('useTripActivityEditor', () => {
);
});
- test('derives bounded day choices from the destination schedule', () => {
+ test('derives bounded day choices from the destination schedule', async () => {
const { props } = setup();
- const { result } = renderHook(() => useTripActivityEditor(props));
+ const { result } = await renderHook(() => useTripActivityEditor(props));
expect(result.current.dayOptions).toEqual([2, 3, 4]);
const unscheduled = setup({
destination: destination({ endDay: null, startDay: null }),
tripDayCount: 3
});
- const fallback = renderHook(() => useTripActivityEditor(unscheduled.props));
+ const fallback = await renderHook(() => useTripActivityEditor(unscheduled.props));
expect(fallback.result.current.dayOptions).toEqual([1, 2, 3]);
});
@@ -163,7 +163,7 @@ describe('useTripActivityEditor', () => {
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('offline'));
const { props } = setup();
- const { result } = renderHook(() => useTripActivityEditor(props));
+ const { result } = await renderHook(() => useTripActivityEditor(props));
const files = [
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
@@ -175,7 +175,7 @@ describe('useTripActivityEditor', () => {
expect(result.current.attachments).toEqual([{ id: id<'media'>('media-a'), name: 'a.pdf' }]);
expect(result.current.uploadMessage).toBe('2 could not be uploaded.');
- act(() =>
+ await act(() =>
result.current.patch({
attachments: [1, 2, 3, 4, 5].map((value) => ({
id: id<'media'>(`media-${value}`),
diff --git a/apps/web/src/features/trips/hooks/use-trip-activity-filters.test.tsx b/apps/web/src/features/trips/hooks/use-trip-activity-filters.test.tsx
index 415d79ba..337257a2 100644
--- a/apps/web/src/features/trips/hooks/use-trip-activity-filters.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-activity-filters.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import { useTripActivityFilters } from './use-trip-activity-filters';
import type { TripDetail } from './use-trips';
@@ -24,8 +24,8 @@ const activity: TripDetail['activity'] = [
];
describe('useTripActivityFilters', () => {
- test('starts with all people and actions visible', () => {
- const { result } = renderHook(() => useTripActivityFilters(activity));
+ test('starts with all people and actions visible', async () => {
+ const { result } = await renderHook(() => useTripActivityFilters(activity));
expect(result.current.visibleActivity).toHaveLength(2);
expect(result.current.hasExtraFilters).toBe(false);
expect(result.current.eventCountLabel).toBe('2 events');
@@ -35,16 +35,16 @@ describe('useTripActivityFilters', () => {
]);
});
- test('filters by person and clears extras', () => {
- const { result } = renderHook(() => useTripActivityFilters(activity));
+ test('filters by person and clears extras', async () => {
+ const { result } = await renderHook(() => useTripActivityFilters(activity));
- act(() => {
+ await act(() => {
result.current.setUserId('user-2');
});
expect(result.current.visibleActivity.map((item) => item.id)).toEqual(['a2']);
expect(result.current.hasExtraFilters).toBe(true);
- act(() => {
+ await act(() => {
result.current.clearFilters();
});
expect(result.current.visibleActivity).toHaveLength(2);
diff --git a/apps/web/src/features/trips/hooks/use-trip-agent-context.test.tsx b/apps/web/src/features/trips/hooks/use-trip-agent-context.test.tsx
index c19bb763..425a5e31 100644
--- a/apps/web/src/features/trips/hooks/use-trip-agent-context.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-agent-context.test.tsx
@@ -1,7 +1,8 @@
import { AgentContextProvider, useCurrentAgentContext } from '@groam/ai/ui/context/agent-context';
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { tripScreenAgentContext, useTripAgentContext } from './use-trip-agent-context';
import type { TripDetail } from './use-trips';
@@ -72,9 +73,7 @@ function Probe() {
return ;
}
-afterEach(cleanup);
-
-test('builds explicit screen data and capabilities for every trip section', () => {
+test('builds explicit screen data and capabilities for every trip section', async () => {
const sections = ['activity', 'issues', 'itinerary', 'overview', 'ideas'] as const;
for (const section of sections) {
const context = tripScreenAgentContext(trip, section);
@@ -147,40 +146,40 @@ test('builds explicit screen data and capabilities for every trip section', () =
});
});
-test('forwards live trip section changes through the shared agent context hook', () => {
- const view = render(
+test('forwards live trip section changes through the shared agent context hook', async () => {
+ const view = await render(
);
- expect(screen.getByRole('status', { name: 'Trip agent context' }).textContent).toContain(
+ expect(page.getByRole('status', { name: 'Trip agent context' }).element().textContent).toContain(
'user-alex'
);
- view.rerender(
+ await view.rerender(
);
- expect(screen.getByRole('status', { name: 'Trip agent context' }).textContent).toContain(
+ expect(page.getByRole('status', { name: 'Trip agent context' }).element().textContent).toContain(
'trip.activity.add'
);
- expect(screen.getByRole('status', { name: 'Trip agent context' }).textContent).not.toContain(
- 'user-alex'
- );
+ expect(
+ page.getByRole('status', { name: 'Trip agent context' }).element().textContent
+ ).not.toContain('user-alex');
- view.rerender(
+ await view.rerender(
);
- expect(screen.getByRole('status', { name: 'Trip agent context' }).textContent).toContain(
+ expect(page.getByRole('status', { name: 'Trip agent context' }).element().textContent).toContain(
'proposal-lisbon'
);
- expect(screen.getByRole('status', { name: 'Trip agent context' }).textContent).not.toContain(
- 'user-alex'
- );
+ expect(
+ page.getByRole('status', { name: 'Trip agent context' }).element().textContent
+ ).not.toContain('user-alex');
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-destination-search.test.tsx b/apps/web/src/features/trips/hooks/use-trip-destination-search.test.tsx
index d154ff74..b12cceef 100644
--- a/apps/web/src/features/trips/hooks/use-trip-destination-search.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-destination-search.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripDestinationSearch } from './use-trip-destination-search';
@@ -19,18 +19,18 @@ afterEach(() => {
});
describe('useTripDestinationSearch', () => {
- test('starts closed and clears query on close', () => {
- const { result } = renderHook(() => useTripDestinationSearch());
+ test('starts closed and clears query on close', async () => {
+ const { result } = await renderHook(() => useTripDestinationSearch());
expect(result.current.isOpen).toBe(false);
expect(result.current.isLoading).toBe(false);
- act(() => {
+ await act(() => {
result.current.open();
result.current.setQuery('Li');
});
expect(result.current.isOpen).toBe(true);
- act(() => {
+ await act(() => {
result.current.onOpenChange(false);
});
expect(result.current.isOpen).toBe(false);
@@ -38,8 +38,8 @@ describe('useTripDestinationSearch', () => {
});
test('debounces search while open', async () => {
- const { result } = renderHook(() => useTripDestinationSearch());
- act(() => {
+ const { result } = await renderHook(() => useTripDestinationSearch());
+ await act(() => {
result.current.open();
result.current.setQuery('Lisbon');
});
@@ -62,27 +62,27 @@ describe('useTripDestinationSearch', () => {
finishOldSearch = resolve;
})
);
- const { result } = renderHook(() => useTripDestinationSearch(true));
- act(() => result.current.setQuery('Lisbon'));
+ const { result } = await renderHook(() => useTripDestinationSearch(true));
+ await act(() => result.current.setQuery('Lisbon'));
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
- act(() => result.current.setQuery('Porto'));
+ await act(() => result.current.setQuery('Porto'));
expect(result.current.isLoading).toBe(true);
expect(result.current.results).toEqual([]);
- await act(async () => {
+ await act(() => {
finishOldSearch([{ name: 'Lisbon' }]);
});
expect(result.current.results).toEqual([]);
expect(result.current.isLoading).toBe(true);
- act(() => result.current.setQuery(''));
+ await act(() => result.current.setQuery(''));
expect(result.current.isLoading).toBe(false);
});
test('shows search errors and handles non-Error rejections', async () => {
search.searchLocations.mockRejectedValue('offline');
- const { result } = renderHook(() => useTripDestinationSearch());
- act(() => {
+ const { result } = await renderHook(() => useTripDestinationSearch());
+ await act(() => {
result.current.open();
result.current.setQuery('Rome');
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-duration-planner.test.tsx b/apps/web/src/features/trips/hooks/use-trip-duration-planner.test.tsx
index 2abc1dec..74af7d64 100644
--- a/apps/web/src/features/trips/hooks/use-trip-duration-planner.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-duration-planner.test.tsx
@@ -1,11 +1,11 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test, vi } from 'vite-plus/test';
import { useTripDurationPlanner } from './use-trip-duration-planner';
describe('useTripDurationPlanner', () => {
test('tracks validity and skips save when unchanged', async () => {
const onSave = vi.fn().mockResolvedValue(true);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripDurationPlanner({
minimumDays: 2,
onSave,
@@ -28,7 +28,7 @@ describe('useTripDurationPlanner', () => {
test('saves when the range changes and closes the field after a full range', async () => {
const onSave = vi.fn().mockResolvedValue(true);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripDurationPlanner({
minimumDays: 2,
onSave,
@@ -37,7 +37,7 @@ describe('useTripDurationPlanner', () => {
})
);
- act(() => {
+ await act(() => {
result.current.setOpenField('start');
result.current.selectRange({
from: new Date('2026-07-01T00:00:00'),
diff --git a/apps/web/src/features/trips/hooks/use-trip-issues.test.tsx b/apps/web/src/features/trips/hooks/use-trip-issues.test.tsx
index d07dade8..8af98ba5 100644
--- a/apps/web/src/features/trips/hooks/use-trip-issues.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-issues.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripIssue, useTripIssues } from './use-trip-issues';
@@ -10,7 +10,10 @@ const convex = vi.hoisted(() => ({
}));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -40,7 +43,7 @@ describe('useTripIssues', () => {
const create = vi.fn().mockResolvedValue(issueId);
convex.useQuery.mockReturnValue([{ id: issueId }]);
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripIssues(tripId));
+ const { result } = await renderHook(() => useTripIssues(tripId));
await act(async () => {
expect(await result.current.createIssue('Coast day', 'Body')).toBe(issueId);
@@ -51,7 +54,7 @@ describe('useTripIssues', () => {
test('returns null and toasts when issue creation fails', async () => {
const create = vi.fn().mockRejectedValue(new Error('Trip is archived'));
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripIssues(tripId));
+ const { result } = await renderHook(() => useTripIssues(tripId));
let created: Id<'tripIssues'> | null = null;
await act(async () => {
@@ -81,7 +84,7 @@ describe('useTripIssue', () => {
.mockReturnValueOnce(status)
.mockReturnValueOnce(due)
.mockReturnValueOnce(assign);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => {
expect(await result.current.addComment('A note')).toBe(true);
@@ -100,7 +103,7 @@ describe('useTripIssue', () => {
const createVersion = vi.fn();
convex.useQuery.mockReturnValue(undefined);
convex.useMutation.mockReturnValue(createVersion);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => expect(await result.current.implement()).toBeNull());
expect(createVersion).not.toHaveBeenCalled();
@@ -109,7 +112,7 @@ describe('useTripIssue', () => {
const comment = vi.fn().mockRejectedValue(new Error('Issue is closed'));
convex.useQuery.mockReturnValue(openIssue);
convex.useMutation.mockReturnValue(comment);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => {
expect(await result.current.addComment('Can we keep this flexible?')).toBe(false);
@@ -123,7 +126,7 @@ describe('useTripIssue', () => {
const status = vi.fn().mockRejectedValue(new Error('Only organizers can close issues'));
convex.useQuery.mockReturnValue(openIssue);
convex.useMutation.mockReturnValue(status);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => {
expect(await result.current.setStatus('closed')).toBe(false);
@@ -137,7 +140,7 @@ describe('useTripIssue', () => {
const assign = vi.fn().mockRejectedValue(new Error('Issue agent is unavailable'));
convex.useQuery.mockReturnValue(openIssue);
convex.useMutation.mockReturnValue(assign);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => {
expect(await result.current.assignIssueAgent()).toBe(false);
@@ -154,7 +157,7 @@ describe('useTripIssue', () => {
const assign = vi.fn().mockRejectedValue(new Error('Only organizers can assign issues'));
convex.useQuery.mockReturnValue(openIssue);
convex.useMutation.mockReturnValue(assign);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
await act(async () => {
expect(await result.current.unassign()).toBe(false);
@@ -168,7 +171,7 @@ describe('useTripIssue', () => {
const createVersion = vi.fn().mockRejectedValue(new Error('Issue already has an idea'));
convex.useQuery.mockReturnValue(openIssue);
convex.useMutation.mockReturnValue(createVersion);
- const { result } = renderHook(() => useTripIssue(issueId));
+ const { result } = await renderHook(() => useTripIssue(issueId));
let version = null;
await act(async () => {
diff --git a/apps/web/src/features/trips/hooks/use-trip-list-auto-load.test.tsx b/apps/web/src/features/trips/hooks/use-trip-list-auto-load.test.tsx
index d679ab6f..ab14eb84 100644
--- a/apps/web/src/features/trips/hooks/use-trip-list-auto-load.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-list-auto-load.test.tsx
@@ -1,14 +1,13 @@
-import { act, cleanup, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripListAutoLoad } from './use-trip-list-auto-load';
afterEach(() => {
- cleanup();
vi.unstubAllGlobals();
});
describe('useTripListAutoLoad', () => {
- test('loads once when visible, waits during loading, and rearms for the next page', () => {
+ test('loads once when visible, waits during loading, and rearms for the next page', async () => {
const callbacks: IntersectionObserverCallback[] = [];
const observe = vi.fn();
const disconnect = vi.fn();
@@ -23,45 +22,43 @@ describe('useTripListAutoLoad', () => {
}
);
const loadMore = vi.fn();
- const { result, rerender, unmount } = renderHook(
+ const { result, rerender, unmount } = await renderHook(
({ status }: { status: 'LoadingMore' | 'CanLoadMore' | 'Exhausted' }) =>
useTripListAutoLoad({ loadMore, status }),
{ initialProps: { status: 'LoadingMore' } }
);
result.current.current = document.createElement('div');
- rerender({ status: 'CanLoadMore' });
- const emit = (index: number, isIntersecting: boolean) =>
- act(() =>
+ await rerender({ status: 'CanLoadMore' });
+ const emit = async (index: number, isIntersecting: boolean) =>
+ await act(() =>
callbacks[index]?.(
[{ isIntersecting } as IntersectionObserverEntry],
{} as IntersectionObserver
)
);
- emit(0, false);
+ await emit(0, false);
expect(loadMore).not.toHaveBeenCalled();
- emit(0, true);
- emit(0, true);
+ await emit(0, true);
+ await emit(0, true);
expect(loadMore).toHaveBeenCalledExactlyOnceWith(25);
- rerender({ status: 'LoadingMore' });
- emit(0, true);
+ await rerender({ status: 'LoadingMore' });
+ await emit(0, true);
expect(loadMore).toHaveBeenCalledTimes(1);
- rerender({ status: 'CanLoadMore' });
- emit(1, true);
+ await rerender({ status: 'CanLoadMore' });
+ await emit(1, true);
expect(loadMore).toHaveBeenCalledTimes(2);
- rerender({ status: 'Exhausted' });
- emit(1, true);
+ await rerender({ status: 'Exhausted' });
+ await emit(1, true);
expect(loadMore).toHaveBeenCalledTimes(2);
expect(observe).toHaveBeenCalledTimes(2);
- unmount();
+ await unmount();
expect(disconnect).toHaveBeenCalled();
});
- test('keeps manual pagination usable when the observer API is unavailable', () => {
+ test('keeps manual pagination usable when the observer API is unavailable', async () => {
vi.stubGlobal('IntersectionObserver', undefined);
const loadMore = vi.fn();
- expect(() =>
- renderHook(() => useTripListAutoLoad({ loadMore, status: 'CanLoadMore' }))
- ).not.toThrow();
+ await renderHook(() => useTripListAutoLoad({ loadMore, status: 'CanLoadMore' }));
expect(loadMore).not.toHaveBeenCalled();
});
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-list-filters.test.tsx b/apps/web/src/features/trips/hooks/use-trip-list-filters.test.tsx
index 4cd50cd7..c59c090d 100644
--- a/apps/web/src/features/trips/hooks/use-trip-list-filters.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-list-filters.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import type { TripListItem } from '@/types/trips';
import { useTripListFilters } from './use-trip-list-filters';
@@ -19,12 +19,12 @@ function trip(value: Partial & Pick):
}
describe('useTripListFilters', () => {
- test('defaults to active and filters archived trips out', () => {
+ test('defaults to active and filters archived trips out', async () => {
const trips = [
trip({ id: 'trips:active' as Id<'trips'>, name: 'Atlantic week' }),
trip({ archivedAt: 10, id: 'trips:archived' as Id<'trips'>, name: 'Last year' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripListFilters({
emptyFilterMessage: (filter) => `empty:${filter}`,
trips
@@ -36,21 +36,21 @@ describe('useTripListFilters', () => {
expect(result.current.empty).toBe('empty:active');
});
- test('clearing filters resets status and query', () => {
- const { result } = renderHook(() =>
+ test('clearing filters resets status and query', async () => {
+ const { result } = await renderHook(() =>
useTripListFilters({
emptyFilterMessage: () => 'none',
trips: []
})
);
- act(() => {
+ await act(() => {
result.current.setStatusFilter('all');
result.current.setQuery('lisbon');
});
expect(result.current.hasFilters).toBe(true);
- act(() => {
+ await act(() => {
result.current.clearFilters();
});
expect(result.current.statusFilter).toBe('active');
@@ -58,19 +58,19 @@ describe('useTripListFilters', () => {
expect(result.current.hasFilters).toBe(false);
});
- test('filters by search query in the visible list', () => {
+ test('filters by search query in the visible list', async () => {
const trips = [
trip({ id: 'trips:one' as Id<'trips'>, name: 'Atlantic week' }),
trip({ id: 'trips:two' as Id<'trips'>, name: 'City break' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripListFilters({
emptyFilterMessage: () => 'none',
trips
})
);
- act(() => {
+ await act(() => {
result.current.setQuery('city');
});
expect(result.current.visibleTrips.map((item) => item.id)).toEqual(['trips:two']);
diff --git a/apps/web/src/features/trips/hooks/use-trip-packing.test.tsx b/apps/web/src/features/trips/hooks/use-trip-packing.test.tsx
index 7feb2ddb..ae290677 100644
--- a/apps/web/src/features/trips/hooks/use-trip-packing.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-packing.test.tsx
@@ -1,12 +1,15 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripPacking } from './use-trip-packing';
const convex = vi.hoisted(() => ({ useMutation: vi.fn(), useQuery: vi.fn() }));
const notifications = vi.hoisted(() => ({ error: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -22,9 +25,9 @@ describe('useTripPacking', () => {
test('adds the draft, clears it after success, and sends trip context', async () => {
const add = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValue(add);
- const { result } = renderHook(() => useTripPacking(tripId));
+ const { result } = await renderHook(() => useTripPacking(tripId));
- act(() => result.current.setDraft(' Passport '));
+ await act(() => result.current.setDraft(' Passport '));
await act(async () => {
await result.current.add();
});
@@ -37,12 +40,12 @@ describe('useTripPacking', () => {
test('skips loading and reports failed updates when no trip is selected', async () => {
const update = vi.fn().mockRejectedValue(new Error('Read only'));
convex.useMutation.mockReturnValueOnce(vi.fn()).mockReturnValueOnce(update);
- const loaded = renderHook(() => useTripPacking(tripId));
+ const loaded = await renderHook(() => useTripPacking(tripId));
await act(() => loaded.result.current.toggle(itemId, true));
expect(update).toHaveBeenCalledWith({ itemId, packed: true, tripId });
expect(notifications.error).toHaveBeenCalledWith('Read only');
- renderHook(() => useTripPacking(undefined));
+ await renderHook(() => useTripPacking(undefined));
expect(convex.useQuery).toHaveBeenLastCalledWith(expect.anything(), 'skip');
});
@@ -53,12 +56,12 @@ describe('useTripPacking', () => {
let mutationIndex = 0;
convex.useMutation.mockImplementation(() => [add, update, remove][mutationIndex++ % 3]);
convex.useQuery.mockReturnValue(undefined);
- const { result } = renderHook(() => useTripPacking(tripId));
+ const { result } = await renderHook(() => useTripPacking(tripId));
await act(() => result.current.add());
expect(add).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(true);
- act(() => result.current.setDraft('Passport'));
+ await act(() => result.current.setDraft('Passport'));
await act(() => result.current.add());
expect(notifications.error).toHaveBeenCalledWith('Cannot add');
await act(() => result.current.remove(itemId));
@@ -71,11 +74,11 @@ describe('useTripPacking', () => {
const remove = vi.fn().mockRejectedValue(new Error('Cannot remove'));
let mutationIndex = 0;
convex.useMutation.mockImplementation(() => [add, update, remove][mutationIndex++ % 3]);
- const selected = renderHook(() => useTripPacking(tripId));
+ const selected = await renderHook(() => useTripPacking(tripId));
await act(() => selected.result.current.remove(itemId));
expect(notifications.error).toHaveBeenCalledWith('Cannot remove');
- const noTrip = renderHook(() => useTripPacking(undefined));
+ const noTrip = await renderHook(() => useTripPacking(undefined));
await act(() => noTrip.result.current.remove(itemId));
expect(remove).toHaveBeenCalledTimes(1);
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-record.test.tsx b/apps/web/src/features/trips/hooks/use-trip-record.test.tsx
index 678d7f89..e804e255 100644
--- a/apps/web/src/features/trips/hooks/use-trip-record.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-record.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { renderHook } from '@testing-library/react';
+import { renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripRecord } from './use-trip-record';
@@ -7,23 +7,26 @@ const convex = vi.hoisted(() => ({
useQuery: vi.fn()
}));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
beforeEach(() => {
vi.clearAllMocks();
});
describe('useTripRecord', () => {
- test('skips the query when trip id is missing', () => {
+ test('skips the query when trip id is missing', async () => {
convex.useQuery.mockReturnValue(undefined);
- renderHook(() => useTripRecord(undefined));
+ await renderHook(() => useTripRecord(undefined));
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), 'skip');
});
- test('loads the trip when an id is provided', () => {
+ test('loads the trip when an id is provided', async () => {
const trip = { id: 'trip-1', name: 'Portugal' };
convex.useQuery.mockReturnValue(trip);
- const { result } = renderHook(() => useTripRecord('trip-1' as Id<'trips'>));
+ const { result } = await renderHook(() => useTripRecord('trip-1' as Id<'trips'>));
expect(result.current).toEqual(trip);
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), {
tripId: 'trip-1'
diff --git a/apps/web/src/features/trips/hooks/use-trip-sidebar-actions.test.tsx b/apps/web/src/features/trips/hooks/use-trip-sidebar-actions.test.tsx
index 21ecd2b0..5f5f81ba 100644
--- a/apps/web/src/features/trips/hooks/use-trip-sidebar-actions.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-sidebar-actions.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripSidebarActions } from './use-trip-sidebar-actions';
@@ -8,7 +8,10 @@ const convex = vi.hoisted(() => ({
}));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -23,7 +26,7 @@ describe('useTripSidebarActions', () => {
const favorite = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValueOnce(archive).mockReturnValueOnce(favorite);
- const { result } = renderHook(() => useTripSidebarActions());
+ const { result } = await renderHook(() => useTripSidebarActions());
await act(async () => {
await result.current.archiveTrip(tripId);
});
@@ -37,7 +40,7 @@ describe('useTripSidebarActions', () => {
const favorite = vi.fn().mockRejectedValue(new Error('Offline'));
convex.useMutation.mockReturnValueOnce(archive).mockReturnValueOnce(favorite);
- const { result } = renderHook(() => useTripSidebarActions());
+ const { result } = await renderHook(() => useTripSidebarActions());
await act(async () => {
await result.current.toggleFavorite(tripId, false);
});
@@ -52,7 +55,7 @@ describe('useTripSidebarActions', () => {
const favorite = vi.fn().mockResolvedValue(null);
convex.useMutation.mockReturnValueOnce(archive).mockReturnValueOnce(favorite);
- const { result } = renderHook(() => useTripSidebarActions());
+ const { result } = await renderHook(() => useTripSidebarActions());
await act(async () => {
await result.current.archiveTrip(tripId);
});
@@ -66,7 +69,7 @@ describe('useTripSidebarActions', () => {
const favorite = vi.fn().mockRejectedValue(new Error('Server Error'));
convex.useMutation.mockReturnValueOnce(archive).mockReturnValueOnce(favorite);
- const { result } = renderHook(() => useTripSidebarActions());
+ const { result } = await renderHook(() => useTripSidebarActions());
await act(async () => {
await result.current.toggleFavorite(tripId, true);
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-stay-editor.test.tsx b/apps/web/src/features/trips/hooks/use-trip-stay-editor.test.tsx
index f07879e6..cc7153aa 100644
--- a/apps/web/src/features/trips/hooks/use-trip-stay-editor.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-stay-editor.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripStayEditor } from './use-trip-stay-editor';
@@ -23,8 +23,8 @@ beforeEach(() => {
});
describe('useTripStayEditor', () => {
- test('opens a new stay and tracks editing boolean', () => {
- const { result } = renderHook(() =>
+ test('opens a new stay and tracks editing boolean', async () => {
+ const { result } = await renderHook(() =>
useTripStayEditor({
addStay: vi.fn(),
destination: destination as never,
@@ -32,7 +32,7 @@ describe('useTripStayEditor', () => {
})
);
expect(result.current.isEditing).toBe(false);
- act(() => {
+ await act(() => {
result.current.open();
});
expect(result.current.isEditing).toBe(true);
@@ -41,7 +41,7 @@ describe('useTripStayEditor', () => {
test('saves a new stay and closes on success', async () => {
const addStay = vi.fn().mockResolvedValue(true);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripStayEditor({
addStay,
destination: destination as never,
@@ -49,7 +49,7 @@ describe('useTripStayEditor', () => {
})
);
- act(() => {
+ await act(() => {
result.current.open();
result.current.patch({ title: 'Hotel' });
});
@@ -64,14 +64,14 @@ describe('useTripStayEditor', () => {
test('keeps editing open for invalid and failed saves', async () => {
const addStay = vi.fn().mockResolvedValue(false);
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripStayEditor({ addStay, destination: destination as never, updateStay: vi.fn() })
);
- act(() => result.current.open());
+ await act(() => result.current.open());
await act(async () => expect(await result.current.save()).toBe(false));
expect(addStay).not.toHaveBeenCalled();
- act(() => result.current.patch({ title: 'Hotel' }));
+ await act(() => result.current.patch({ title: 'Hotel' }));
await act(async () => expect(await result.current.save()).toBe(false));
expect(addStay).toHaveBeenCalled();
expect(result.current.isEditing).toBe(true);
@@ -89,11 +89,11 @@ describe('useTripStayEditor', () => {
title: 'Hotel',
attachments: []
};
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripStayEditor({ addStay: vi.fn(), destination: destination as never, updateStay })
);
- act(() => result.current.open(stay as never));
+ await act(() => result.current.open(stay as never));
await act(async () => expect(await result.current.save()).toBe(true));
expect(updateStay).toHaveBeenCalledWith(stay.id, expect.anything());
@@ -107,14 +107,14 @@ describe('useTripStayEditor', () => {
});
test('closes and safely ignores empty upload selections', async () => {
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripStayEditor({
addStay: vi.fn(),
destination: destination as never,
updateStay: vi.fn()
})
);
- act(() => {
+ await act(() => {
result.current.open();
result.current.close();
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-transfer-form.test.tsx b/apps/web/src/features/trips/hooks/use-trip-transfer-form.test.tsx
index ca339d71..1d244c93 100644
--- a/apps/web/src/features/trips/hooks/use-trip-transfer-form.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-transfer-form.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripTransferForm } from './use-trip-transfer-form';
@@ -19,16 +19,18 @@ function options(
};
}
-beforeEach(() => vi.clearAllMocks());
+beforeEach(() => {
+ vi.clearAllMocks();
+});
describe('useTripTransferForm', () => {
test('creates a normalized, typed activity transfer draft', async () => {
const onSave = vi.fn().mockResolvedValue(true);
- const { result } = renderHook(() => useTripTransferForm(options({ onSave })));
+ const { result } = await renderHook(() => useTripTransferForm(options({ onSave })));
expect(result.current.mode).toBe('walk');
expect(result.current.canSave).toBe(true);
- act(() => {
+ await act(() => {
result.current.setMode('public_transit');
result.current.setDuration('35');
result.current.setTiming({
@@ -58,16 +60,16 @@ describe('useTripTransferForm', () => {
test('explains every invalid duration and preserves a failed draft', async () => {
const onSave = vi.fn().mockResolvedValue(false);
- const { result } = renderHook(() => useTripTransferForm(options({ onSave })));
+ const { result } = await renderHook(() => useTripTransferForm(options({ onSave })));
- act(() => result.current.setDuration('1.5'));
+ await act(() => result.current.setDuration('1.5'));
expect(result.current.durationError).toBe('Use a whole number of minutes.');
expect(result.current.canSave).toBe(false);
- act(() => result.current.setDuration('0'));
+ await act(() => result.current.setDuration('0'));
expect(result.current.durationError).toBe('Duration must be at least 1 minute.');
- act(() => result.current.setDuration('10081'));
+ await act(() => result.current.setDuration('10081'));
expect(result.current.durationError).toBe('Duration cannot exceed 7 days.');
- act(() => result.current.setDuration('45'));
+ await act(() => result.current.setDuration('45'));
await act(() => result.current.save());
@@ -78,7 +80,7 @@ describe('useTripTransferForm', () => {
});
});
- test('detects unchanged edits and enables save only after a meaningful change', () => {
+ test('detects unchanged edits and enables save only after a meaningful change', async () => {
const initial = {
attachments: [{ id: mediaId('media-1'), name: 'ticket.pdf' }],
durationMinutes: 90,
@@ -86,17 +88,17 @@ describe('useTripTransferForm', () => {
notes: 'Carriage 4',
timing: null
};
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripTransferForm(options({ initial, kind: 'destination' }))
);
expect(result.current.hasChanges).toBe(false);
expect(result.current.canSave).toBe(false);
expect(result.current.mode).toBe('train');
- act(() => result.current.setNotes('Carriage 5'));
+ await act(() => result.current.setNotes('Carriage 5'));
expect(result.current.hasChanges).toBe(true);
expect(result.current.canSave).toBe(true);
- act(() => result.current.removeAttachment(mediaId('media-1')));
+ await act(() => result.current.removeAttachment(mediaId('media-1')));
expect(result.current.attachments).toEqual([]);
});
@@ -114,12 +116,12 @@ describe('useTripTransferForm', () => {
startTime: '08:35'
}
};
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripTransferForm(options({ initial, kind: 'destination', onSave }))
);
expect(result.current.timingEnabled).toBe(true);
- act(() => result.current.setExactTiming(false));
+ await act(() => result.current.setExactTiming(false));
expect(result.current.hasChanges).toBe(true);
await act(() => result.current.save());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ timing: undefined }));
@@ -142,7 +144,7 @@ describe('useTripTransferForm', () => {
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
new File(['c'], 'c.pdf', { type: 'application/pdf' })
];
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripTransferForm(options({ initial, kind: 'destination', uploadMedia }))
);
@@ -167,7 +169,7 @@ describe('useTripTransferForm', () => {
new File(['b'], 'invalid.exe'),
new File(['c'], 'offline.pdf', { type: 'application/pdf' })
];
- const { result } = renderHook(() => useTripTransferForm(options({ uploadMedia })));
+ const { result } = await renderHook(() => useTripTransferForm(options({ uploadMedia })));
await act(() => result.current.uploadFiles(files));
@@ -188,7 +190,7 @@ describe('useTripTransferForm', () => {
notes: null,
timing: null
};
- const existing = renderHook(() => useTripTransferForm(options({ initial, onRemove })));
+ const existing = await renderHook(() => useTripTransferForm(options({ initial, onRemove })));
await act(() => existing.result.current.remove());
expect(existing.result.current.status).toEqual({
@@ -196,7 +198,7 @@ describe('useTripTransferForm', () => {
message: 'The travel connection could not be removed.'
});
- const fresh = renderHook(() => useTripTransferForm(options({ onRemove })));
+ const fresh = await renderHook(() => useTripTransferForm(options({ onRemove })));
await act(() => fresh.result.current.remove());
expect(onRemove).toHaveBeenCalledTimes(1);
});
@@ -210,7 +212,7 @@ describe('useTripTransferForm', () => {
notes: null,
timing: null
};
- const { result } = renderHook(() => useTripTransferForm(options({ initial, onRemove })));
+ const { result } = await renderHook(() => useTripTransferForm(options({ initial, onRemove })));
await act(() => result.current.remove());
@@ -233,11 +235,11 @@ describe('useTripTransferForm', () => {
notes: null,
timing: null
};
- const { result } = renderHook(() =>
+ const { result } = await renderHook(() =>
useTripTransferForm(options({ initial, kind: 'destination', onSave }))
);
- act(() => result.current.setNotes('Delayed'));
+ await act(() => result.current.setNotes('Delayed'));
await act(() => result.current.save());
expect(result.current.status).toEqual({
kind: 'error',
diff --git a/apps/web/src/features/trips/hooks/use-trip-travelers.test.tsx b/apps/web/src/features/trips/hooks/use-trip-travelers.test.tsx
index 23e8e9d2..7fcc784e 100644
--- a/apps/web/src/features/trips/hooks/use-trip-travelers.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-travelers.test.tsx
@@ -1,12 +1,15 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { useTripTravelers } from './use-trip-travelers';
const convex = vi.hoisted(() => ({ useMutation: vi.fn(), useQuery: vi.fn() }));
const notifications = vi.hoisted(() => ({ error: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -25,7 +28,7 @@ describe('useTripTravelers', () => {
{ status: 'going' }
]);
convex.useMutation.mockReturnValue(set);
- const { result } = renderHook(() => useTripTravelers(tripId));
+ const { result } = await renderHook(() => useTripTravelers(tripId));
await act(async () => {
expect(await result.current.setStatus('maybe', 'user-2')).toBe(true);
@@ -39,7 +42,7 @@ describe('useTripTravelers', () => {
const set = vi.fn().mockRejectedValue(new Error('Read only'));
convex.useQuery.mockReturnValue(undefined);
convex.useMutation.mockReturnValue(set);
- const { result } = renderHook(() => useTripTravelers(undefined));
+ const { result } = await renderHook(() => useTripTravelers(undefined));
expect(result.current.isLoading).toBe(true);
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), 'skip');
@@ -48,7 +51,7 @@ describe('useTripTravelers', () => {
});
expect(set).not.toHaveBeenCalled();
- const withTrip = renderHook(() => useTripTravelers(tripId));
+ const withTrip = await renderHook(() => useTripTravelers(tripId));
await act(async () => {
expect(await withTrip.result.current.setStatus('not_going')).toBe(false);
});
diff --git a/apps/web/src/features/trips/hooks/use-trip-versions.test.tsx b/apps/web/src/features/trips/hooks/use-trip-versions.test.tsx
index 8e47463c..5abd374d 100644
--- a/apps/web/src/features/trips/hooks/use-trip-versions.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trip-versions.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, expect, test, vi } from 'vite-plus/test';
import { useTripProposalDetail, useTripVersion, useTripVersions } from './use-trip-versions';
@@ -10,7 +10,10 @@ const convex = vi.hoisted(() => ({
}));
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
const tripId = 'trip-1' as Id<'trips'>;
@@ -26,7 +29,7 @@ beforeEach(() => {
test('creates an isolated version and returns its working trip', async () => {
const create = vi.fn().mockResolvedValue({ proposalId, workingTripId: tripId });
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripVersions(tripId));
+ const { result } = await renderHook(() => useTripVersions(tripId));
let version = null;
await act(async () => {
@@ -40,7 +43,7 @@ test('creates an isolated version and returns its working trip', async () => {
test('passes a custom idea name to Convex', async () => {
const create = vi.fn().mockResolvedValue({ proposalId, workingTripId: tripId });
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripVersions(tripId));
+ const { result } = await renderHook(() => useTripVersions(tripId));
await act(async () => {
await result.current.createVersion({ ideaName: 'itinerary/add-coast-day' });
@@ -52,7 +55,7 @@ test('passes a custom idea name to Convex', async () => {
test('uses skip while versions are disabled and announces a titled idea', async () => {
const create = vi.fn().mockResolvedValue({ proposalId, workingTripId: tripId });
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripVersions(tripId, false));
+ const { result } = await renderHook(() => useTripVersions(tripId, false));
await act(async () => {
await result.current.createVersion({
@@ -74,7 +77,7 @@ test('uses Convex actions for submit, apply, and rebase workflows', async () =>
.mockReturnValueOnce(submit)
.mockReturnValueOnce(merge)
.mockReturnValueOnce(rebase);
- const { result } = renderHook(() => useTripVersion(proposalId));
+ const { result } = await renderHook(() => useTripVersion(proposalId));
await act(async () => {
expect(await result.current.submit()).toBe(true);
@@ -95,7 +98,7 @@ test('reports a clean idea review clearly', async () => {
.mockReturnValueOnce(vi.fn())
.mockReturnValueOnce(vi.fn())
.mockReturnValueOnce(review);
- const { result } = renderHook(() => useTripVersion(proposalId));
+ const { result } = await renderHook(() => useTripVersion(proposalId));
await act(async () => {
expect(await result.current.requestAgentReview()).toBe(true);
@@ -109,7 +112,7 @@ test('reports a clean idea review clearly', async () => {
test('reports failed version actions without claiming success', async () => {
convex.useAction.mockReturnValue(vi.fn().mockRejectedValue(new Error('Git unavailable')));
- const { result } = renderHook(() => useTripVersion(proposalId));
+ const { result } = await renderHook(() => useTripVersion(proposalId));
await act(async () => {
expect(await result.current.submit()).toBe(false);
@@ -122,7 +125,7 @@ test('runs approval, close, feedback, reviewer, resolve, and agent-review workfl
const mutation = vi.fn().mockResolvedValue(null);
convex.useAction.mockReturnValue(action);
convex.useMutation.mockReturnValue(mutation);
- const { result } = renderHook(() => useTripVersion(proposalId));
+ const { result } = await renderHook(() => useTripVersion(proposalId));
await act(async () => {
expect(await result.current.approve(false)).toBe(true);
@@ -153,7 +156,7 @@ test('runs approval, close, feedback, reviewer, resolve, and agent-review workfl
test('returns null and reports failed rebases and agent reviews', async () => {
const failed = vi.fn().mockRejectedValue(new Error('Remote unavailable'));
convex.useAction.mockReturnValue(failed);
- const { result } = renderHook(() => useTripVersion(proposalId));
+ const { result } = await renderHook(() => useTripVersion(proposalId));
await act(async () => {
expect(await result.current.rebase([{ choice: 'proposed', path: 'details/name' }])).toBeNull();
@@ -162,15 +165,15 @@ test('returns null and reports failed rebases and agent reviews', async () => {
expect(notifications.error).toHaveBeenCalledWith('Remote unavailable');
});
-test('skips idea detail queries until a proposal id is available', () => {
- renderHook(() => useTripProposalDetail());
+test('skips idea detail queries until a proposal id is available', async () => {
+ await renderHook(() => useTripProposalDetail());
expect(convex.useQuery).toHaveBeenCalledWith(expect.anything(), 'skip');
});
test('returns null and toasts when idea creation fails', async () => {
const create = vi.fn().mockRejectedValue(new Error('Trip is archived'));
convex.useMutation.mockReturnValue(create);
- const { result } = renderHook(() => useTripVersions(tripId));
+ const { result } = await renderHook(() => useTripVersions(tripId));
let version = null;
await act(async () => {
diff --git a/apps/web/src/features/trips/hooks/use-trips-sidebar-ui.test.tsx b/apps/web/src/features/trips/hooks/use-trips-sidebar-ui.test.tsx
index 9aa27a18..5855e692 100644
--- a/apps/web/src/features/trips/hooks/use-trips-sidebar-ui.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trips-sidebar-ui.test.tsx
@@ -1,5 +1,5 @@
import type { Id } from '@groam/backend/data-model';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { describe, expect, test } from 'vite-plus/test';
import type { TripListItem } from './use-trips';
import { useTripsSidebarUi } from './use-trips-sidebar-ui';
@@ -7,14 +7,14 @@ import { useTripsSidebarUi } from './use-trips-sidebar-ui';
const trip = { id: 'trip-1' as Id<'trips'>, name: 'Portugal' } as TripListItem;
describe('useTripsSidebarUi', () => {
- test('tracks create, archive, and exclusive menu booleans', () => {
- const { result } = renderHook(() => useTripsSidebarUi());
+ test('tracks create, archive, and exclusive menu booleans', async () => {
+ const { result } = await renderHook(() => useTripsSidebarUi());
expect(result.current.isCreateOpen).toBe(false);
expect(result.current.isArchiveOpen).toBe(false);
expect(result.current.menuTripId).toBeNull();
- act(() => {
+ await act(() => {
result.current.openCreate();
result.current.requestArchive(trip);
result.current.onMenuOpenChange(trip.id, true);
@@ -24,7 +24,7 @@ describe('useTripsSidebarUi', () => {
expect(result.current.tripToArchive).toEqual(trip);
expect(result.current.menuTripId).toBe(trip.id);
- act(() => {
+ await act(() => {
result.current.onMenuOpenChange(trip.id, false);
result.current.onArchiveOpenChange(false);
result.current.closeCreate();
@@ -34,14 +34,14 @@ describe('useTripsSidebarUi', () => {
expect(result.current.isCreateOpen).toBe(false);
});
- test('sets create state from a controlled open-change callback', () => {
- const { result } = renderHook(() => useTripsSidebarUi());
+ test('sets create state from a controlled open-change callback', async () => {
+ const { result } = await renderHook(() => useTripsSidebarUi());
- act(() => result.current.setCreateOpen(true));
+ await act(() => result.current.setCreateOpen(true));
expect(result.current.isCreateOpen).toBe(true);
- act(() => result.current.setCreateOpen(false));
+ await act(() => result.current.setCreateOpen(false));
expect(result.current.isCreateOpen).toBe(false);
- act(() => result.current.clearArchive());
+ await act(() => result.current.clearArchive());
expect(result.current.tripToArchive).toBeNull();
});
});
diff --git a/apps/web/src/features/trips/hooks/use-trips.test.tsx b/apps/web/src/features/trips/hooks/use-trips.test.tsx
index 7e634c75..118fe472 100644
--- a/apps/web/src/features/trips/hooks/use-trips.test.tsx
+++ b/apps/web/src/features/trips/hooks/use-trips.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook } from '@groam/vitest-config/react';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { CreateTripInput } from './use-trips';
import { useTrip, useTrips, useWorkspaceTripProposals } from './use-trips';
@@ -15,7 +15,10 @@ const storage = vi.hoisted(() => ({
uploadToConvexStorage: vi.fn()
}));
-vi.mock('convex/react', () => convex);
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...convex
+}));
vi.mock('@groam/ui/components/toast', () => ({ toast: notifications }));
vi.mock('@/features/media/convex-storage-upload', () => storage);
vi.mock('@/features/workspace/workspace-shell/workspace-state', () => ({
@@ -43,7 +46,9 @@ describe('useTrips', () => {
const upload = vi.fn();
convex.useAction.mockReturnValue(create);
convex.useMutation.mockReturnValue(upload);
- const { result } = renderHook(() => useTrips({ includeArchived: false, initialNumItems: 10 }));
+ const { result } = await renderHook(() =>
+ useTrips({ includeArchived: false, initialNumItems: 10 })
+ );
await act(async () => {
expect(await result.current.createTrip(input, null)).toBe('trip-1');
@@ -69,7 +74,7 @@ describe('useTrips', () => {
json: async () => ({ storageId: 'raw-id' })
});
storage.storageIdFromUploadResponse.mockReturnValue('storage-1');
- const { result } = renderHook(() => useTrips());
+ const { result } = await renderHook(() => useTrips());
const valid = new File(['cover'], 'cover.png', { type: 'image/png' });
await act(async () => {
@@ -95,14 +100,14 @@ describe('useTrips', () => {
});
});
-test('skips trip pagination on request and exposes workspace proposals', () => {
- const skipped = renderHook(() => useTrips('skip'));
+test('skips trip pagination on request and exposes workspace proposals', async () => {
+ const skipped = await renderHook(() => useTrips('skip'));
expect(convex.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', {
initialNumItems: 25
});
expect(skipped.result.current.trips).toEqual([{ id: 'trip-1', name: 'Portugal' }]);
- const proposals = renderHook(() => useWorkspaceTripProposals({ initialNumItems: 5 }));
+ const proposals = await renderHook(() => useWorkspaceTripProposals({ initialNumItems: 5 }));
expect(convex.usePaginatedQuery).toHaveBeenLastCalledWith(
expect.anything(),
{},
@@ -122,7 +127,7 @@ test('exposes trip mutations for planning, travel details, and cover management'
json: async () => ({ storageId: 'raw' })
});
storage.storageIdFromUploadResponse.mockReturnValue('cover-storage');
- const { result } = renderHook(() => useTrip('trip-1' as never));
+ const { result } = await renderHook(() => useTrip('trip-1' as never));
await act(async () => {
await result.current.archive();
@@ -171,7 +176,7 @@ test('reports failed trip actions and treats null or loading trips as absent', a
const failedArchive = vi.fn().mockRejectedValue(new Error('Read only'));
convex.useMutation.mockReturnValue(failedArchive);
convex.useQuery.mockReturnValue(null);
- const missing = renderHook(() => useTrip('trip-1' as never));
+ const missing = await renderHook(() => useTrip('trip-1' as never));
await act(async () => {
expect(await missing.result.current.archive()).toBe(false);
});
@@ -180,6 +185,6 @@ test('reports failed trip actions and treats null or loading trips as absent', a
expect(missing.result.current.trip).toBeUndefined();
convex.useQuery.mockReturnValue(undefined);
- const loading = renderHook(() => useTrip('trip-1' as never));
+ const loading = await renderHook(() => useTrip('trip-1' as never));
expect(loading.result.current.exists).toBeUndefined();
});
diff --git a/apps/web/src/features/trips/itinerary-editor/editor-destination-photo.test.tsx b/apps/web/src/features/trips/itinerary-editor/editor-destination-photo.test.tsx
index efcf1f1c..c267d990 100644
--- a/apps/web/src/features/trips/itinerary-editor/editor-destination-photo.test.tsx
+++ b/apps/web/src/features/trips/itinerary-editor/editor-destination-photo.test.tsx
@@ -1,18 +1,21 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { EditorDestinationPhoto } from './editor-destination-photo';
-afterEach(cleanup);
+// A one-pixel PNG the browser can really load.
+const pixel =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
-test('replaces a broken photo and recovers when a new photo arrives', () => {
- const { rerender } = render();
- fireEvent.error(screen.getByRole('img', { name: 'Lisbon' }));
- expect(screen.getByRole('img', { name: 'Lisbon · Photo unavailable' })).toBeTruthy();
- rerender();
- expect(screen.getByRole('img', { name: 'Lisbon' }).getAttribute('src')).toBe('/lisbon-new.jpg');
+test('replaces a broken photo and recovers when a new photo arrives', async () => {
+ const { rerender } = await render();
+ // The missing path fails to load in the browser, as a broken photo would.
+ await expect.element(page.getByRole('img', { name: 'Lisbon · Photo unavailable' })).toBeVisible();
+ await rerender();
+ await expect.element(page.getByRole('img', { name: 'Lisbon' })).toHaveAttribute('src', pixel);
});
-test('keeps missing thumbnails decorative when the destination is already named', () => {
- render();
- expect(screen.queryByRole('img')).toBeNull();
+test('keeps missing thumbnails decorative when the destination is already named', async () => {
+ await render();
+ await expect.element(page.getByRole('img')).not.toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/itinerary-editor/itinerary-editor.test.tsx b/apps/web/src/features/trips/itinerary-editor/itinerary-editor.test.tsx
new file mode 100644
index 00000000..a81745a3
--- /dev/null
+++ b/apps/web/src/features/trips/itinerary-editor/itinerary-editor.test.tsx
@@ -0,0 +1,400 @@
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { testIds } from '@/lib/test-ids';
+import { StatefulItineraryEditor } from '@/testing/itinerary-editor-fixture';
+import { itineraryCalls, itineraryTrip, plannedActivity } from '@/testing/itinerary-fixture';
+
+vi.mock('@/features/media/hooks/use-media-upload', () => ({
+ useMediaUpload: () => vi.fn()
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+const day = (number: number) => page.getByRole('region', { name: `Day ${number} schedule` });
+const editor = () => page.getByRole('region', { name: /^(?:Add an activity to|Edit) /u });
+const dayCell = (scope: ReturnType, number: number) =>
+ scope.getByRole('button', { name: new RegExp(`^Day ${number}(?:,|$)`, 'u') });
+
+async function openAddMenu(number: number) {
+ await day(number).getByRole('button', { name: 'Add to day' }).click();
+}
+
+async function startActivity(number: number) {
+ await openAddMenu(number);
+ await page.getByRole('menuitem', { name: 'Activity' }).click();
+}
+
+describe('day headers', () => {
+ test.each([
+ [4, 'Porto'],
+ [5, 'Porto'],
+ [6, 'Braga']
+ ])('Day %i names the destination it is spent in', async (number, name) => {
+ await render(
+
+ );
+ await expect
+ .element(day(number).getByRole('button', { name: `Manage ${name} for Day ${number}` }))
+ .toBeVisible();
+ await expect.element(day(number).getByRole('heading', { name })).toBeVisible();
+ });
+
+ test('an unplanned day invites a destination instead of naming one', async () => {
+ await render(
+
+ );
+ expect(day(2).element().textContent).toContain('A day to explore');
+ expect(day(2).element().textContent).toContain('Add a destination to start planning this day.');
+ });
+});
+
+describe('adding activities from later days', () => {
+ test.each([1, 2, 3])(
+ 'Day %i has no destination, so it cannot take an activity',
+ async (number) => {
+ await render(
+
+ );
+ await openAddMenu(number);
+ await expect.element(page.getByRole('menuitem', { name: 'Activity' })).toBeDisabled();
+ await expect.element(page.getByRole('menuitem', { name: 'Stay' })).toBeDisabled();
+ await expect.element(page.getByRole('menuitem', { name: 'Destination' })).toBeEnabled();
+ }
+ );
+
+ test.each([4, 5])('Day %i hosts the destination and opens the inline form', async (number) => {
+ await render(
+
+ );
+ await startActivity(number);
+ await expect
+ .element(editor())
+ .toHaveAttribute('aria-label', `Add an activity to Day ${number}`);
+ await expect.element(editor().getByRole('button', { name: 'Add to day' })).toBeDisabled();
+ expect(editor().element().textContent).toContain(`Day ${number} · Porto`);
+ });
+
+ test('adds, edits, round-trips the address and removes a later-day activity', async () => {
+ await render(
+
+ );
+ await startActivity(4);
+ await editor().getByTestId(testIds.activityTitle).fill('Porto river walk');
+ await editor().getByRole('button', { name: 'Add to day' }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+ await expect
+ .element(day(4).getByRole('button', { name: 'Edit Porto river walk' }))
+ .toBeVisible();
+ expect(itineraryCalls.addActivity).toHaveBeenCalledOnce();
+
+ await day(4).getByRole('button', { name: 'Edit Porto river walk' }).click();
+ await editor().getByTestId(testIds.activityAddress).fill('Ribeira, Porto');
+ await editor().getByRole('button', { name: 'Save changes' }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+
+ await day(4).getByRole('button', { name: 'Edit Porto river walk' }).click();
+ await expect
+ .element(editor().getByTestId(testIds.activityAddress))
+ .toHaveValue('Ribeira, Porto');
+ await editor().getByRole('button', { name: 'Remove Porto river walk' }).click();
+ await editor().getByRole('button', { name: 'Remove plan' }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Edit Porto river walk' }))
+ .not.toBeInTheDocument();
+
+ await startActivity(5);
+ await editor().getByTestId(testIds.activityTitle).fill('Evening cruise');
+ await editor().getByRole('button', { name: 'Add to day' }).click();
+ await expect.element(day(5).getByRole('button', { name: 'Edit Evening cruise' })).toBeVisible();
+ });
+});
+
+describe('cancelling an edit', () => {
+ const boatTrip = () =>
+ itineraryTrip([
+ {
+ activities: [
+ plannedActivity({
+ address: 'Original meeting point',
+ dayNumber: 1,
+ endDayNumber: 1,
+ id: 'boat',
+ title: 'Boat trip'
+ })
+ ],
+ endDay: 3,
+ name: 'Porto',
+ startDay: 1
+ }
+ ]);
+
+ test.each(['Cancel', 'Close activity editor'])(
+ '%s discards unsaved changes and leaves the saved plan untouched',
+ async (control) => {
+ await render();
+ await page.getByRole('button', { name: 'Edit Boat trip' }).click();
+ await editor().getByTestId(testIds.activityAddress).fill('Unsaved meeting point');
+ await editor().getByRole('button', { name: control }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+ expect(itineraryCalls.updateActivity).not.toHaveBeenCalled();
+
+ await page.getByRole('button', { name: 'Edit Boat trip' }).click();
+ await expect
+ .element(editor().getByTestId(testIds.activityAddress))
+ .toHaveValue('Original meeting point');
+ }
+ );
+
+ test('asks before removing and keeps the plan when declined', async () => {
+ await render();
+ await page.getByRole('button', { name: 'Edit Boat trip' }).click();
+ await expect
+ .element(editor().getByRole('button', { name: 'Remove plan' }))
+ .not.toBeInTheDocument();
+ await editor().getByRole('button', { name: 'Remove Boat trip' }).click();
+ await expect.element(editor().getByRole('button', { name: 'Remove plan' })).toBeVisible();
+ expect(editor().element().textContent).toContain('Remove Boat trip from this idea?');
+ await editor().getByRole('button', { name: 'Keep plan' }).click();
+ await expect
+ .element(editor().getByRole('button', { name: 'Remove plan' }))
+ .not.toBeInTheDocument();
+ await editor().getByRole('button', { name: 'Cancel' }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+ expect(itineraryCalls.removeActivity).not.toHaveBeenCalled();
+ await expect.element(page.getByRole('button', { name: 'Edit Boat trip' })).toBeVisible();
+ });
+
+ test('removes the plan only after the confirmation', async () => {
+ await render();
+ await page.getByRole('button', { name: 'Edit Boat trip' }).click();
+ await editor().getByRole('button', { name: 'Remove Boat trip' }).click();
+ await editor().getByRole('button', { name: 'Remove plan' }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Edit Boat trip' }))
+ .not.toBeInTheDocument();
+ expect(itineraryCalls.removeActivity).toHaveBeenCalledOnce();
+ });
+});
+
+describe('multi-day activities', () => {
+ const festival = () =>
+ itineraryTrip([
+ {
+ activities: [
+ plannedActivity({
+ dayNumber: 4,
+ endDayNumber: 6,
+ id: 'festival',
+ timeBlock: 'full_day',
+ title: 'Three-day festival'
+ })
+ ],
+ endDay: 6,
+ name: 'Porto',
+ startDay: 4
+ }
+ ]);
+
+ test.each([4, 5, 6])('Day %i lists the activity and opens the whole range', async (number) => {
+ await render();
+ await expect.element(day(number).getByText('Three-day festival')).toBeVisible();
+ await day(number).getByRole('button', { name: 'Edit Three-day festival' }).click();
+ expect(editor().element().textContent).toContain('Days 4–6 · Porto');
+ await expect
+ .element(editor().getByTestId(testIds.activityTitle))
+ .toHaveValue('Three-day festival');
+ await expect.element(editor().getByRole('button', { name: 'Save changes' })).toBeEnabled();
+ await expect.element(dayCell(editor(), 4)).toHaveAttribute('aria-pressed', 'true');
+ await expect.element(dayCell(editor(), 6)).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ test('saving from a continuation day keeps the full range', async () => {
+ await render();
+ await day(6).getByRole('button', { name: 'Edit Three-day festival' }).click();
+ await editor().getByTestId(testIds.activityAddress).fill('Praça da Liberdade');
+ await editor().getByRole('button', { name: 'Save changes' }).click();
+ expect(itineraryCalls.updateActivity).toHaveBeenCalledWith(
+ 'festival',
+ expect.objectContaining({
+ address: 'Praça da Liberdade',
+ schedule: expect.objectContaining({ day: 4, endDay: 6 })
+ })
+ );
+ });
+});
+
+describe('inline day planning', () => {
+ const lisbon = () => itineraryTrip([{ endDay: 3, name: 'Lisbon', startDay: 1 }]);
+
+ async function addInline(number: number, title: string, period: string) {
+ await startActivity(number);
+ await editor().getByTestId(testIds.activityTitle).fill(title);
+ const choice = editor().getByRole('button', { name: period });
+ await choice.click();
+ await expect.element(choice).toHaveAttribute('aria-pressed', 'true');
+ await editor().getByRole('button', { name: 'Add to day' }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+ }
+
+ test('lists untimed plans in the order they were added, morning then afternoon', async () => {
+ await render();
+ await addInline(2, 'Market breakfast', 'Morning');
+ await addInline(2, 'Riverside stroll', 'Afternoon');
+
+ const plans = day(2).getByRole('button', {
+ name: /^Edit (?:Market breakfast|Riverside stroll)$/u
+ });
+ await expect.poll(() => plans.elements()).toHaveLength(2);
+ await expect.element(plans.first()).toHaveAccessibleName('Edit Market breakfast');
+ await expect.element(plans.last()).toHaveAccessibleName('Edit Riverside stroll');
+ });
+
+ test('orders timed plans by start time regardless of insertion order', async () => {
+ await render(
+
+ );
+ const plans = day(2).getByRole('button', {
+ name: /^Edit (?:Market breakfast|Riverside stroll)$/u
+ });
+ await expect.element(plans.first()).toHaveAccessibleName('Edit Market breakfast');
+ await expect.element(plans.last()).toHaveAccessibleName('Edit Riverside stroll');
+ });
+
+ test('reopens a plan on its time of day and saves an address', async () => {
+ await render();
+ await addInline(2, 'Riverside stroll', 'Afternoon');
+ await day(2).getByRole('button', { name: 'Edit Riverside stroll' }).click();
+ await expect.element(editor()).toHaveAttribute('aria-label', 'Edit Riverside stroll');
+ await expect
+ .element(editor().getByRole('button', { name: 'Afternoon' }))
+ .toHaveAttribute('aria-pressed', 'true');
+ await expect
+ .element(editor().getByRole('button', { name: 'Morning' }))
+ .toHaveAttribute('aria-pressed', 'false');
+ await editor().getByTestId(testIds.activityAddress).fill('Ribeira das Naus, Lisbon');
+ await editor().getByRole('button', { name: 'Save changes' }).click();
+ await expect.element(editor()).not.toBeInTheDocument();
+ await expect
+ .element(day(2).getByRole('button', { name: 'Edit Riverside stroll' }))
+ .toBeVisible();
+ });
+
+ test('keeps notes behind "More options" until asked for', async () => {
+ await render();
+ await startActivity(2);
+ await expect.element(editor().getByTestId(testIds.activityNotes)).not.toBeVisible();
+ await editor()
+ .getByText(/More options/u)
+ .click();
+ await expect.element(editor().getByTestId(testIds.activityNotes)).toBeVisible();
+ });
+
+ test('offers a destination choice only on a day shared by several stops', async () => {
+ await render();
+ await startActivity(2);
+ await expect
+ .element(editor().getByRole('combobox', { name: 'Destination' }))
+ .not.toBeInTheDocument();
+ });
+
+ test('switching a shared day’s destination keeps the draft and resets days outside it', async () => {
+ await render(
+
+ );
+ await startActivity(2);
+ await editor().getByTestId(testIds.activityTitle).fill('Riverside picnic');
+ await editor().getByRole('button', { name: 'Afternoon' }).click();
+ await editor()
+ .getByText(/More options/u)
+ .click();
+ await dayCell(editor(), 1).click();
+ await dayCell(editor(), 2).click();
+ await expect.element(dayCell(editor(), 1)).toHaveAttribute('aria-pressed', 'true');
+
+ await editor().getByRole('combobox', { name: 'Destination' }).click();
+ await page.getByRole('option', { name: 'Porto' }).click();
+ await expect
+ .element(editor().getByTestId(testIds.activityTitle))
+ .toHaveValue('Riverside picnic');
+ await expect
+ .element(editor().getByRole('button', { name: 'Afternoon' }))
+ .toHaveAttribute('aria-pressed', 'true');
+ await expect.element(dayCell(editor(), 2)).toHaveAttribute('aria-pressed', 'true');
+ await expect.element(dayCell(editor(), 1)).toHaveAttribute('aria-pressed', 'false');
+ await expect.element(dayCell(editor(), 1)).toBeDisabled();
+
+ await editor().getByTestId(testIds.activityNotes).fill('Meet by the old bridge');
+ await editor().getByRole('button', { name: 'Add to day' }).click();
+ const row = day(2)
+ .getByRole('button', { name: 'Edit Riverside picnic' })
+ .element()
+ .closest('[data-slot="timeline-item"]');
+ expect(row?.textContent).toContain('Porto');
+ });
+
+ test('keeps the inline form inside the schedule on a phone without overflow', async () => {
+ await useViewport(390, 844);
+ await render(
+
+ );
+ await expect.element(day(2).getByRole('button', { name: 'Add to day' })).toBeVisible();
+ await addInline(2, 'Morning market', 'Morning');
+ await expect.element(day(2).getByRole('button', { name: 'Edit Morning market' })).toBeVisible();
+ await startActivity(3);
+ await expect
+ .element(day(3).getByRole('region', { name: 'Add an activity to Day 3' }))
+ .toBeVisible();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
+ });
+});
diff --git a/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-details-resolve-sheet.test.tsx b/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-details-resolve-sheet.test.tsx
index b96fd660..047d3046 100644
--- a/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-details-resolve-sheet.test.tsx
+++ b/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-details-resolve-sheet.test.tsx
@@ -1,17 +1,15 @@
-import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
-import { stubMatchMedia } from '@/testing/stub-match-media';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { ItineraryDetailsResolveSheet } from './itinerary-details-resolve-sheet';
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children }: { children: React.ReactNode }) => {children}
}));
-afterEach(cleanup);
-
-test('locks the entire resolution dialog while choices are applying', () => {
- stubMatchMedia();
- render(
+test('locks the entire resolution dialog while choices are applying', async () => {
+ await render(
{
/>
);
- expect(screen.getByRole('dialog').getAttribute('aria-busy')).toBe('true');
- expect(screen.getByText('Applying your choices…')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Cancel' }).hasAttribute('disabled')).toBe(true);
- expect(screen.getByRole('button', { name: 'Use all shared' }).hasAttribute('disabled')).toBe(
- true
- );
- expect(
- screen.getByRole('button', { name: 'Keep shared trip for Trip name' }).hasAttribute('disabled')
- ).toBe(true);
+ await expect.element(page.getByRole('dialog')).toHaveAttribute('aria-busy', 'true');
+ await expect.element(page.getByText('Applying your choices…')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+ await expect.element(page.getByRole('button', { name: 'Use all shared' })).toBeDisabled();
+ await expect
+ .element(page.getByRole('button', { name: 'Keep shared trip for Trip name' }))
+ .toBeDisabled();
});
-test('keeps the shared branch and idea branch distinct through result preview', () => {
- stubMatchMedia();
- render(
+test('keeps the shared branch and idea branch distinct through result preview', async () => {
+ await render(
);
- const comparedBranches = screen.getByLabelText('Compared sides');
+ const comparedBranches = page.getByLabelText('Compared sides').element();
expect(comparedBranches.textContent).toContain('Summer group trip');
expect(comparedBranches.textContent).toContain('Lisbon alternatives');
- const sharedBranch = screen.getByRole('region', { name: 'Shared trip branch for Trip name' });
- const ideaBranch = screen.getByRole('region', { name: 'This idea branch for Trip name' });
- expect(sharedBranch.textContent).toContain('Shared trip name');
- expect(sharedBranch.textContent).not.toContain('Idea trip name');
- expect(ideaBranch.textContent).toContain('Idea trip name');
- expect(ideaBranch.textContent).not.toContain('Shared trip name');
+ const sharedBranch = page.getByRole('region', { name: 'Shared trip branch for Trip name' });
+ const ideaBranch = page.getByRole('region', { name: 'This idea branch for Trip name' });
+ expect(sharedBranch.element().textContent).toContain('Shared trip name');
+ expect(sharedBranch.element().textContent).not.toContain('Idea trip name');
+ expect(ideaBranch.element().textContent).toContain('Idea trip name');
+ expect(ideaBranch.element().textContent).not.toContain('Shared trip name');
- fireEvent.click(within(ideaBranch).getByRole('button', { name: 'Keep my idea for Trip name' }));
- fireEvent.click(screen.getByRole('button', { name: 'Review result' }));
- const preview = screen.getByRole('region', { name: 'Result preview' });
+ await ideaBranch.getByRole('button', { name: 'Keep my idea for Trip name' }).click();
+ await page.getByRole('button', { name: 'Review result' }).click();
+ const preview = page.getByRole('region', { name: 'Result preview' }).element();
expect(preview.textContent).toContain('From your idea · Lisbon alternatives');
expect(preview.textContent).toContain('Idea trip name');
});
diff --git a/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-proposal-highlight.test.tsx b/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-proposal-highlight.test.tsx
index cd580a17..4eb1cf92 100644
--- a/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-proposal-highlight.test.tsx
+++ b/apps/web/src/features/trips/itinerary-proposal-highlight/itinerary-proposal-highlight.test.tsx
@@ -1,11 +1,10 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { ItineraryChange } from '@/features/trips/hooks/itinerary-proposal-changes';
import { ItineraryDetailsChangeCard } from './itinerary-details-change-card';
import { ItineraryProposalHighlight } from './itinerary-proposal-highlight';
-afterEach(cleanup);
-
const change = (overrides: Partial = {}): ItineraryChange => ({
change: 'modified',
entity: 'destination',
@@ -29,27 +28,27 @@ function highlight(kind: ItineraryChange['change']) {
}
describe('ItineraryProposalHighlight', () => {
- test('leaves unchanged itinerary items without idea chrome', () => {
- render(
+ test('leaves unchanged itinerary items without idea chrome', async () => {
+ await render(
Lisbon
);
- expect(screen.getByRole('heading', { name: 'Lisbon' })).toBeTruthy();
- expect(screen.queryByText('New')).toBeNull();
- expect(screen.queryByText('Changed')).toBeNull();
- expect(screen.queryByText('Removed from shared trip')).toBeNull();
+ await expect.element(page.getByRole('heading', { name: 'Lisbon' })).toBeInTheDocument();
+ await expect.element(page.getByText('New')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Changed')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Removed from shared trip')).not.toBeInTheDocument();
expect(document.querySelector('[data-proposal-change]')).toBeNull();
});
- test('marks added, changed, and removed items with a quiet left rail instead of a full outline', () => {
- const { rerender } = render(
+ test('marks added, changed, and removed items with a quiet left rail instead of a full outline', async () => {
+ const { rerender } = await render(
Porto
);
- expect(screen.getByText('New').className).toContain('sr-only');
+ await expect.element(page.getByText('New')).toHaveClass('sr-only');
expect(highlight('added')?.className).toContain('p-0');
expect(highlight('added')?.className).not.toContain('p-2');
expect(highlight('added')?.className).not.toContain('border-emerald-500');
@@ -59,13 +58,15 @@ describe('ItineraryProposalHighlight', () => {
);
expect(document.querySelector('[data-slot="badge"]')).toBeNull();
- rerender(
+ await rerender(
Lisbon
);
- expect(screen.getByText('Changed').className).toContain('sr-only');
- expect(screen.getByText('Day notes: Lisbon notes → Porto notes')).toBeTruthy();
+ await expect.element(page.getByText('Changed')).toHaveClass('sr-only');
+ await expect
+ .element(page.getByText('Day notes: Lisbon notes → Porto notes'))
+ .toBeInTheDocument();
expect(highlight('modified')?.className).toContain('p-0');
expect(highlight('modified')?.className).not.toContain('p-2');
expect(highlight('modified')?.className).not.toContain('border-amber-500');
@@ -74,12 +75,12 @@ describe('ItineraryProposalHighlight', () => {
);
expect(document.querySelector('[data-slot="badge"]')).toBeNull();
- rerender(
+ await rerender(
Faro
);
- expect(screen.getByText('Removed from shared trip').className).toContain('sr-only');
+ await expect.element(page.getByText('Removed from shared trip')).toHaveClass('sr-only');
expect(highlight('removed')?.className).toContain('p-0');
expect(highlight('removed')?.className).not.toContain('p-2');
expect(highlight('removed')?.className).toContain('opacity-70');
@@ -92,8 +93,8 @@ describe('ItineraryProposalHighlight', () => {
});
describe('ItineraryDetailsChangeCard', () => {
- test('renders trip-detail diffs in a border well with warmer copy and diff pills', () => {
- render(
+ test('renders trip-detail diffs in a border well with warmer copy and diff pills', async () => {
+ await render(
{
/>
);
- const title = screen.getByRole('heading', {
- name: 'Trip details differ from the shared trip'
- });
+ const title = page
+ .getByRole('heading', {
+ name: 'Trip details differ from the shared trip'
+ })
+ .element();
const card = title.closest('section');
expect(card?.className).toContain('border');
expect(card?.className).toContain('rounded-lg');
@@ -131,25 +134,29 @@ describe('ItineraryDetailsChangeCard', () => {
expect(card?.className).not.toContain('dashboard-panel');
expect(card?.className).not.toContain('border-amber-500');
expect(card?.getAttribute('data-proposal-change')).toBe('modified');
- expect(screen.getByText('Conflict')).toBeTruthy();
- expect(
- screen.getByText(
- 'The shared trip and your idea have different trip details. Choose what to keep.'
+ await expect.element(page.getByText('Conflict')).toBeInTheDocument();
+ await expect
+ .element(
+ page.getByText(
+ 'The shared trip and your idea have different trip details. Choose what to keep.'
+ )
)
- ).toBeTruthy();
- expect(screen.queryByText(/Duration: 11 days → 42 days · Start date/)).toBeNull();
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByText(/Duration: 11 days → 42 days · Start date/))
+ .not.toBeInTheDocument();
const text = card?.textContent ?? '';
expect(text.indexOf('Trip details differ from the shared trip')).toBeLessThan(
text.indexOf('Duration')
);
- expect(screen.getByText('Duration')).toBeTruthy();
- expect(screen.getByText('11 days')).toBeTruthy();
- expect(screen.getByText('42 days')).toBeTruthy();
- expect(screen.getByText('Start date')).toBeTruthy();
- expect(screen.getByText('Not set')).toBeTruthy();
- expect(screen.getByText('2026-08-12')).toBeTruthy();
- expect(screen.getByLabelText('What changed')).toBeTruthy();
+ await expect.element(page.getByText('Duration')).toBeInTheDocument();
+ await expect.element(page.getByText('11 days')).toBeInTheDocument();
+ await expect.element(page.getByText('42 days')).toBeInTheDocument();
+ await expect.element(page.getByText('Start date')).toBeInTheDocument();
+ await expect.element(page.getByText('Not set')).toBeInTheDocument();
+ await expect.element(page.getByText('2026-08-12')).toBeInTheDocument();
+ await expect.element(page.getByLabelText('What changed')).toBeInTheDocument();
expect(document.querySelector('[data-slot="badge"]')).toBeNull();
});
});
diff --git a/apps/web/src/features/trips/test-workspace-mocks.tsx b/apps/web/src/features/trips/test-workspace-mocks.tsx
index 266c087d..09854101 100644
--- a/apps/web/src/features/trips/test-workspace-mocks.tsx
+++ b/apps/web/src/features/trips/test-workspace-mocks.tsx
@@ -8,34 +8,38 @@ export const trips = {
useWorkspaceTripProposals: vi.fn()
};
-vi.mock('@groam/ai/ui/context/agent-context', () => ({
- useSetAgentContext: () => undefined
-}));
+// Module doubles for views that render the trips workspace. `vi.mock` only registers when it is
+// called from the test file itself, so each test file wires these in:
+// vi.mock('', async () => (await import('@/features/trips/test-workspace-mocks')).);
+export const agentContextModule = { useSetAgentContext: () => undefined };
-vi.mock('@/features/workspace/workspace-shell/workspace-state', () => ({
+export const workspaceStateModule = {
useWorkspace: () => ({
activeOrganization: { id: 'org-acme', name: 'Acme Labs' },
session: { user: { id: 'user-alex' } }
})
-}));
+};
-vi.mock('@/features/trips/hooks/use-trips', () => ({
+export const useTripsModule = {
useTrips: (...args: unknown[]) => trips.useTrips(...args),
useWorkspaceTripProposals: (...args: unknown[]) => trips.useWorkspaceTripProposals(...args)
-}));
+};
-vi.mock('@tanstack/react-router', () => ({
- Link: ({
- children,
- to
- }: {
- children: ReactNode;
- params?: unknown;
- search?: unknown;
- to: string;
- }) => {children},
- useNavigate: () => navigate
-}));
+export async function tripRouterModule() {
+ return {
+ ...(await vi.importActual('@tanstack/react-router')),
+ Link: ({
+ children,
+ to
+ }: {
+ children: ReactNode;
+ params?: unknown;
+ search?: unknown;
+ to: string;
+ }) => {children},
+ useNavigate: () => navigate
+ };
+}
export function resetTripWorkspaceView() {
vi.clearAllMocks();
diff --git a/apps/web/src/features/trips/trip-activity/trip-activity-card.test.tsx b/apps/web/src/features/trips/trip-activity/trip-activity-card.test.tsx
index af6f01a3..854ee970 100644
--- a/apps/web/src/features/trips/trip-activity/trip-activity-card.test.tsx
+++ b/apps/web/src/features/trips/trip-activity/trip-activity-card.test.tsx
@@ -1,13 +1,12 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { ItineraryChange } from '@/features/trips/hooks/itinerary-proposal-changes';
import type { TripActivity } from '@/features/trips/hooks/use-trip-activity-editor';
import { ItineraryProposalHighlight } from '@/features/trips/itinerary-proposal-highlight/itinerary-proposal-highlight';
import { TripActivityCard } from '@/features/trips/trip-activity/trip-activity-card';
-afterEach(cleanup);
-
function activity(overrides: Partial = {}): TripActivity {
return {
address: null,
@@ -34,8 +33,8 @@ function card() {
}
describe('TripActivityCard', () => {
- test('sits on the page background without a filled card surface', () => {
- render(
+ test('sits on the page background without a filled card surface', async () => {
+ await render(
{
expect(card()?.className).toContain('border-border');
expect(card()?.className).toContain('bg-transparent');
expect(card()?.className).not.toContain('bg-card');
- expect(screen.getByRole('heading', { name: 'Explore the neighborhood' })).toBeTruthy();
- expect(screen.getByText('Day 1 · Morning')).toBeTruthy();
- expect(
- screen.queryByRole('button', { name: 'Actions for Explore the neighborhood' })
- ).toBeNull();
+ await expect
+ .element(page.getByRole('heading', { name: 'Explore the neighborhood' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Day 1 · Morning')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Actions for Explore the neighborhood' }))
+ .not.toBeInTheDocument();
});
- test('keeps the overflow menu when the traveler can manage', () => {
- render(
+ test('keeps the overflow menu when the traveler can manage', async () => {
+ await render(
{
/>
);
- expect(
- screen.getByRole('button', { name: 'Actions for Explore the neighborhood' })
- ).toBeTruthy();
+ await expect
+ .element(page.getByRole('button', { name: 'Actions for Explore the neighborhood' }))
+ .toBeInTheDocument();
});
- test('proposal highlight uses a left rail around an unfilled activity, not a full outline', () => {
+ test('proposal highlight uses a left rail around an unfilled activity, not a full outline', async () => {
const change: ItineraryChange = {
change: 'added',
entity: 'activity',
@@ -83,7 +84,7 @@ describe('TripActivityCard', () => {
label: 'Explore the neighborhood'
};
- render(
+ await render(
{
+test('explains an invalid cost instead of silently disabling the activity submit button', async () => {
const editor = {
...emptyActivityForm({
startDay: 1,
@@ -23,11 +22,11 @@ test('explains an invalid cost instead of silently disabling the activity submit
patch: vi.fn(),
tripStartDate: null
} as unknown as TripActivityEditor;
- render();
- expect(screen.getByText('Cost must be zero or more.')).toBeDefined();
+ await render();
+ await expect.element(page.getByText('Cost must be zero or more.')).toBeInTheDocument();
});
-test('keeps optional activity details collapsed for a quick inline add', () => {
+test('keeps optional activity details collapsed for a quick inline add', async () => {
const editor = {
...emptyActivityForm({
startDay: 2,
@@ -39,12 +38,15 @@ test('keeps optional activity details collapsed for a quick inline add', () => {
patch: vi.fn(),
tripStartDate: null
} as unknown as TripActivityEditor;
- render();
+ await render();
- expect(screen.getByTestId('activity-title')).toBeDefined();
- const disclosure = screen.getByText(/More options: dates/u).closest('details');
+ await expect.element(page.getByTestId('activity-title')).toBeInTheDocument();
+ const disclosure = page
+ .getByText(/More options: dates/u)
+ .element()
+ .closest('details');
expect(disclosure?.open).toBe(false);
- fireEvent.click(screen.getByText(/More options: dates/u));
+ await page.getByText(/More options: dates/u).click();
expect(disclosure?.open).toBe(true);
- expect(screen.getByText('Estimated cost for the group (EUR)')).toBeDefined();
+ await expect.element(page.getByText('Estimated cost for the group (EUR)')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-activity/trip-activity-history.test.tsx b/apps/web/src/features/trips/trip-activity/trip-activity-history.test.tsx
index a660ffc2..8efa680b 100644
--- a/apps/web/src/features/trips/trip-activity/trip-activity-history.test.tsx
+++ b/apps/web/src/features/trips/trip-activity/trip-activity-history.test.tsx
@@ -1,11 +1,10 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import { TripActivityHistory } from './trip-activity-history';
-afterEach(cleanup);
-
const activity = [
{
actorName: 'Alex Traveler',
@@ -25,35 +24,48 @@ const activity = [
}
] satisfies TripDetail['activity'];
-test('renders activity events in a data table with compact filters', () => {
- render();
+test('renders activity events in a data table with compact filters', async () => {
+ await render();
- expect(screen.getByRole('heading', { level: 2, name: 'Activity' })).toBeTruthy();
- expect(screen.getByText('Every planning move the group has made on this trip.')).toBeTruthy();
- expect(screen.getByText('2 events')).toBeTruthy();
- const toolbar = document.querySelector('[aria-label="Filter activity"]');
- expect(toolbar?.className).toContain('flex');
- expect(toolbar?.className).toContain('items-center');
- expect(toolbar?.className).not.toContain('dashboard-panel');
- expect(document.querySelector('table')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Event' })).toBeTruthy();
- expect(screen.getByLabelText('Search activity')).toBeTruthy();
- expect(screen.getByLabelText('Filter activity by person').textContent).toContain('People');
- expect(screen.getByLabelText('Filter activity by action').textContent).toContain('Actions');
- expect(screen.getByText('Alex Traveler created the trip in Idea & Group')).toBeTruthy();
- expect(screen.getByText('Trip created')).toBeTruthy();
- expect(screen.getByText('Idea applied')).toBeTruthy();
+ await expect
+ .element(page.getByRole('heading', { level: 2, name: 'Activity' }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByText('Every planning move the group has made on this trip.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('2 events')).toBeInTheDocument();
+ const toolbar = page.getByLabelText('Filter activity');
+ await expect.element(toolbar).toHaveClass('flex', 'items-center');
+ await expect.element(toolbar).not.toHaveClass('dashboard-panel');
+ await expect.element(page.getByRole('table')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Event' })).toBeInTheDocument();
+ await expect.element(page.getByLabelText('Search activity')).toBeInTheDocument();
+ expect(page.getByLabelText('Filter activity by person').element().textContent).toContain(
+ 'People'
+ );
+ expect(page.getByLabelText('Filter activity by action').element().textContent).toContain(
+ 'Actions'
+ );
+ await expect
+ .element(page.getByText('Alex Traveler created the trip in Idea & Group'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Trip created')).toBeInTheDocument();
+ await expect.element(page.getByText('Idea applied')).toBeInTheDocument();
expect(document.querySelector('[data-slot="timeline"]')).toBeNull();
});
-test('searches visible activity fields and can clear the filter', () => {
- render();
+test('searches visible activity fields and can clear the filter', async () => {
+ await render();
- fireEvent.change(screen.getByLabelText('Search activity'), { target: { value: 'route' } });
- expect(screen.queryByText('Alex Traveler created the trip in Idea & Group')).toBeNull();
- expect(screen.getByText('Sam Planner applied the route update')).toBeTruthy();
+ await page.getByLabelText('Search activity').fill('route');
+ await expect
+ .element(page.getByText('Alex Traveler created the trip in Idea & Group'))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByText('Sam Planner applied the route update')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Clear activity filters' }));
- expect(screen.getByText('Alex Traveler created the trip in Idea & Group')).toBeTruthy();
- expect(screen.getByText('Sam Planner applied the route update')).toBeTruthy();
+ await page.getByRole('button', { name: 'Clear activity filters' }).click();
+ await expect
+ .element(page.getByText('Alex Traveler created the trip in Idea & Group'))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Sam Planner applied the route update')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-create/create-trip-dialog.test.tsx b/apps/web/src/features/trips/trip-create/create-trip-dialog.test.tsx
index 5600ef25..5f995ceb 100644
--- a/apps/web/src/features/trips/trip-create/create-trip-dialog.test.tsx
+++ b/apps/web/src/features/trips/trip-create/create-trip-dialog.test.tsx
@@ -1,6 +1,9 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { type CreateTripFormInput, fillCreateTripDialog } from '@groam/app-actions/vitest';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { CreateTripDialog } from '@/features/trips/trip-create/create-trip-dialog';
+import { testIds } from '@/lib/test-ids';
vi.mock('@/features/workspace/workspace-shell/workspace-state', () => ({
useWorkspace: () => ({
@@ -26,86 +29,115 @@ vi.mock('@groam/ui/components/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() }
}));
-afterEach(cleanup);
+async function renderDialog() {
+ const createTrip = vi.fn().mockResolvedValue('trip_123');
+ const onCreated = vi.fn();
+ await render(
+
+ );
+ return { createTrip, onCreated };
+}
-test('omits the cover image field from create trip', () => {
- render();
+const name = 'Coast weekend';
- expect(screen.queryByLabelText(/cover image/iu)).toBeNull();
- expect(screen.queryByText(/find a location photo/iu)).toBeNull();
- expect(screen.queryByTestId('create-trip-cover')).toBeNull();
-});
+test('omits the cover image field from create trip', async () => {
+ await renderDialog();
-test('groups related fields under where, when, money, and people sections', () => {
- render();
-
- expect(screen.getByRole('heading', { name: 'Where' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'When' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Money' })).toBeTruthy();
- expect(screen.getByRole('heading', { name: 'Who’s coming' })).toBeTruthy();
- expect(screen.getByText('Alex (you)')).toBeTruthy();
- expect(screen.getByText('Maya')).toBeTruthy();
- expect(screen.queryByRole('checkbox')).toBeNull();
- expect(screen.getByRole('button', { name: 'Invite to this group' })).toBeTruthy();
- expect(screen.getByLabelText(/trip name/iu)).toBeTruthy();
- expect(screen.getByLabelText(/start date/iu)).toBeTruthy();
- expect(screen.getByLabelText(/length \(days\)/iu)).toBeTruthy();
- expect(screen.getByLabelText(/if dates are still open/iu)).toBeTruthy();
- expect(screen.getByLabelText(/default currency/iu)).toBeTruthy();
- expect(screen.getByLabelText(/total group budget/iu)).toBeTruthy();
+ await expect.element(page.getByLabelText(/cover image/iu)).not.toBeInTheDocument();
+ await expect.element(page.getByText(/find a location photo/iu)).not.toBeInTheDocument();
+ await expect.element(page.getByTestId('create-trip-cover')).not.toBeInTheDocument();
});
-test('keeps create disabled until a trip name is entered', () => {
- render();
-
- expect(screen.getByRole('button', { name: 'Create trip' })).toHaveProperty('disabled', true);
+test('groups related fields under where, when, money, and people sections', async () => {
+ await renderDialog();
+ await page.getByText(/add dates and budget/iu).click();
+
+ for (const heading of ['Where', 'When', 'Money', 'Who’s coming']) {
+ await expect.element(page.getByRole('heading', { name: heading })).toBeVisible();
+ }
+ await expect.element(page.getByText('Alex (you)')).toBeVisible();
+ await expect.element(page.getByText('Maya')).toBeVisible();
+ await expect.element(page.getByRole('checkbox')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Invite to this group' })).toBeVisible();
+ for (const label of [
+ /trip name/iu,
+ /start date/iu,
+ /length \(days\)/iu,
+ /if dates are still open/iu,
+ /default currency/iu,
+ /total group budget/iu
+ ]) {
+ await expect.element(page.getByLabelText(label)).toBeInTheDocument();
+ }
+});
- fireEvent.change(screen.getByLabelText(/trip name/iu), {
- target: { value: 'Summer beach trip' }
- });
+test('keeps create disabled until a trip name is entered', async () => {
+ await renderDialog();
+ const submit = page.getByRole('button', { name: 'Create trip' });
- expect(screen.getByRole('button', { name: 'Create trip' })).toHaveProperty('disabled', false);
+ await expect.element(submit).toBeDisabled();
+ await page.getByLabelText(/trip name/iu).fill('Summer beach trip');
+ await expect.element(submit).toBeEnabled();
});
-test('creates a trip without a cover file', async () => {
- const createTrip = vi.fn().mockResolvedValue('trip_123');
- const onCreated = vi.fn();
- render();
-
- fireEvent.change(screen.getByLabelText(/trip name/iu), {
- target: { value: 'Coast weekend' }
- });
- fireEvent.change(screen.getByLabelText(/if dates are still open/iu), {
- target: { value: 'Late July' }
- });
- fireEvent.change(screen.getByLabelText(/length \(days\)/iu), {
- target: { value: '8' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Create trip' }));
-
- await waitFor(() => expect(createTrip).toHaveBeenCalledTimes(1));
- const [input, cover] = createTrip.mock.calls[0] as [
+test.each<[string, CreateTripFormInput, Record]>([
+ ['only a name', { name }, {}],
+ ['open dates', { dateNotes: 'Late July', name }, { dateNotes: 'Late July' }],
+ [
+ 'a start date and length',
+ { durationDays: 8, name, startDate: '2027-07-01' },
+ { duration: { totalDays: 8 }, startDate: '2027-07-01' }
+ ],
+ ['a budget', { budget: 2500, name }, { budget: { amount: 2500 } }],
+ [
+ 'every detail',
+ { budget: 900, dateNotes: 'Flexible', durationDays: 3, name, startDate: '2027-05-10' },
+ {
+ budget: { amount: 900 },
+ dateNotes: 'Flexible',
+ duration: { totalDays: 3 },
+ startDate: '2027-05-10'
+ }
+ ]
+])('creates a trip with %s and no cover file', async (_, input, expected) => {
+ const { createTrip, onCreated } = await renderDialog();
+ await fillCreateTripDialog(input);
+
+ await expect.poll(() => onCreated.mock.calls).toEqual([['trip_123']]);
+ expect(createTrip).toHaveBeenCalledExactlyOnceWith(
{
- dateNotes?: string;
- duration?: { totalDays: number };
- name: string;
+ clientRequestId: expect.any(String),
+ currency: expect.any(String),
+ destination: { status: 'undecided' },
+ name,
+ ...expected
},
- File | null
- ];
- expect(cover).toBeNull();
- expect(input.name).toBe('Coast weekend');
- expect(input.dateNotes).toBe('Late July');
- expect(input.duration).toEqual({ totalDays: 8 });
- expect(onCreated).toHaveBeenCalledWith('trip_123');
+ null
+ );
});
-test('shows every group member in a compact traveler list', () => {
- render();
+test.each<[string, CreateTripFormInput, string]>([
+ ['a zero-day length', { durationDays: 0, name }, testIds.createTripDuration],
+ ['a negative budget', { budget: -5, name }, testIds.createTripBudget]
+])('rejects %s without creating a trip', async (_, input, field) => {
+ const { createTrip } = await renderDialog();
+ await fillCreateTripDialog(input);
+
+ await expect.element(page.getByTestId(field)).toBeInvalid();
+ expect(createTrip).not.toHaveBeenCalled();
+});
- expect(screen.getByText('Traveler 1')).toBeTruthy();
- expect(screen.getByText('Traveler 28')).toBeTruthy();
- expect(screen.getAllByTestId('create-trip-traveler')).toHaveLength(30);
- expect(screen.queryByText(/more$/u)).toBeNull();
- expect(screen.getByText('Shared with your group. Confirm travelers later.')).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'No length preset' })).toBeNull();
+test('shows every group member in a compact traveler list', async () => {
+ await renderDialog();
+
+ await expect.element(page.getByText('Traveler 1')).toBeVisible();
+ await expect.element(page.getByText('Traveler 28')).toBeVisible();
+ expect(page.getByTestId('create-trip-traveler').elements()).toHaveLength(30);
+ await expect.element(page.getByText(/more$/u)).not.toBeInTheDocument();
+ await expect
+ .element(page.getByText('Shared with your group. Confirm travelers later.'))
+ .toBeVisible();
+ await expect
+ .element(page.getByRole('button', { name: 'No length preset' }))
+ .not.toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-destinations/destination-stop-marker.test.tsx b/apps/web/src/features/trips/trip-destinations/destination-stop-marker.test.tsx
index 9a99bf1a..d7fc509c 100644
--- a/apps/web/src/features/trips/trip-destinations/destination-stop-marker.test.tsx
+++ b/apps/web/src/features/trips/trip-destinations/destination-stop-marker.test.tsx
@@ -1,31 +1,42 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { DestinationStopMarker } from './destination-stop-marker';
-afterEach(cleanup);
+// Real images: a one-pixel PNG loads, a missing path fails, so the browser fires the events.
+const pixel =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
-test('announces photo discovery and then image loading until the image arrives', () => {
- const { container, rerender } = render(
+test('announces photo discovery and then image loading until the image arrives', async () => {
+ const { rerender } = await render(
);
- expect(screen.getByRole('status').textContent).toContain('Finding a photo of Lisbon');
- rerender(
-
+ expect(page.getByRole('status').element().textContent).toContain('Finding a photo of Lisbon');
+ await rerender(
+
);
- expect(screen.getByRole('status').textContent).toContain('Loading photo of Lisbon');
- const photo = container.querySelector('img');
- if (!photo) throw new Error('Expected destination photo');
- fireEvent.load(photo);
- expect(screen.queryByRole('status')).toBeNull();
- rerender(
-
+ expect(page.getByRole('status').element().textContent).toContain('Loading photo of Lisbon');
+ await expect.element(page.getByRole('status')).not.toBeInTheDocument();
+ await rerender(
+
);
- expect(screen.getByRole('status').textContent).toContain('Loading photo of Lisbon');
+ expect(page.getByRole('status').element().textContent).toContain('Loading photo of Lisbon');
+ await expect.element(page.getByRole('status')).not.toBeInTheDocument();
});
-test('hands failed images back to the compact destination fallback', () => {
+test('hands failed images back to the compact destination fallback', async () => {
const onCoverError = vi.fn();
- const { container } = render(
+ await render(
{
stop={1}
/>
);
- const photo = container.querySelector('img');
- if (!photo) throw new Error('Expected destination photo');
- fireEvent.error(photo);
- expect(onCoverError).toHaveBeenCalledOnce();
+ await expect.poll(() => onCoverError.mock.calls.length).toBe(1);
});
diff --git a/apps/web/src/features/trips/trip-destinations/destination-travel-leg.test.tsx b/apps/web/src/features/trips/trip-destinations/destination-travel-leg.test.tsx
index eb9f8a07..ccd42338 100644
--- a/apps/web/src/features/trips/trip-destinations/destination-travel-leg.test.tsx
+++ b/apps/web/src/features/trips/trip-destinations/destination-travel-leg.test.tsx
@@ -1,8 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { testIds } from '@/lib/test-ids';
-import { stubMatchMedia } from '@/testing/stub-match-media';
import { DestinationTravelLeg } from './destination-travel-leg';
import type { Destination, TripActivityActions } from './destination-types';
@@ -10,8 +10,6 @@ vi.mock('@/features/trips/hooks/use-itinerary-proposal-changes', () => ({
useItineraryChange: () => null
}));
-afterEach(() => cleanup());
-
const id = (value: string) => value as Id;
function destination(overrides: Partial = {}): Destination {
@@ -40,7 +38,7 @@ const actions = {
setDestinationTransfer: vi.fn()
} as unknown as TripActivityActions;
-function renderLeg(
+async function renderLeg(
options: {
canManage?: boolean;
next?: Destination | undefined;
@@ -57,7 +55,6 @@ function renderLeg(
startDay: 5
}))
: undefined;
- stubMatchMedia();
return render(
{
- const { container } = renderLeg();
+test('shows a full-width hop between stops with no timeline rail', async () => {
+ const { container } = await renderLeg();
expect(container.querySelector('[data-slot="destination-stop-rail"]')).toBeNull();
- const hop = screen.getByTestId(testIds.destinationTravelLeg);
- expect(hop).toBeTruthy();
- expect(hop.className).toContain('w-full');
-
- expect(hop.className).not.toContain('justify-center');
- expect(hop.className).not.toContain('py-8');
- const leg = hop.querySelector('[data-slot="destination-travel-leg"]');
+ const hop = page.getByTestId(testIds.destinationTravelLeg);
+ await expect.element(hop).toHaveClass('w-full');
+ await expect.element(hop).not.toHaveClass('justify-center');
+ await expect.element(hop).not.toHaveClass('py-8');
+ const leg = hop.element().querySelector('[data-slot="destination-travel-leg"]');
expect(leg?.className).toContain('w-full');
expect(leg?.className).not.toContain('max-w-sm');
expect(leg?.className).not.toContain('bg-muted');
- expect(screen.getByText('How we go')).toBeTruthy();
- expect(screen.getByText('Lisbon')).toBeTruthy();
- expect(screen.getByText('Porto')).toBeTruthy();
- expect(
- screen.getByText('Travel not planned yet — flight, train, drive, ferry, or TBD.')
- ).toBeTruthy();
- expect(screen.getByTestId(testIds.planTravel)).toBeTruthy();
- expect(screen.getByText('Plan travel')).toBeTruthy();
+ await expect.element(page.getByText('How we go')).toBeInTheDocument();
+ await expect.element(page.getByText('Lisbon')).toBeInTheDocument();
+ await expect.element(page.getByText('Porto')).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Travel not planned yet — flight, train, drive, ferry, or TBD.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.planTravel)).toBeInTheDocument();
+ await expect.element(page.getByText('Plan travel')).toBeInTheDocument();
});
-test('keeps the empty hop for viewers who cannot edit travel', () => {
- renderLeg({ canManage: false });
+test('keeps the empty hop for viewers who cannot edit travel', async () => {
+ await renderLeg({ canManage: false });
- expect(screen.getByTestId(testIds.destinationTravelLeg)).toBeTruthy();
- expect(
- screen.getByText('Travel not planned yet — flight, train, drive, ferry, or TBD.')
- ).toBeTruthy();
- expect(screen.queryByTestId(testIds.planTravel)).toBeNull();
+ await expect.element(page.getByTestId(testIds.destinationTravelLeg)).toBeInTheDocument();
+ await expect
+ .element(page.getByText('Travel not planned yet — flight, train, drive, ferry, or TBD.'))
+ .toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.planTravel)).not.toBeInTheDocument();
});
-test('renders nothing after the last stop', () => {
- const { container } = renderLeg({}, false);
+test('renders nothing after the last stop', async () => {
+ const { container } = await renderLeg({}, false);
expect(container.firstChild).toBeNull();
});
diff --git a/apps/web/src/features/trips/trip-destinations/scheduled-destination-days.test.tsx b/apps/web/src/features/trips/trip-destinations/scheduled-destination-days.test.tsx
index cfea5563..0c579da7 100644
--- a/apps/web/src/features/trips/trip-destinations/scheduled-destination-days.test.tsx
+++ b/apps/web/src/features/trips/trip-destinations/scheduled-destination-days.test.tsx
@@ -1,6 +1,7 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { Destination } from './destination-types';
import { ScheduledDestinationDays } from './scheduled-destination-days';
@@ -27,15 +28,13 @@ function destination(
};
}
-afterEach(cleanup);
-
-test('marks sibling stop days as taken in the day strip', () => {
+test('marks sibling stop days as taken in the day strip', async () => {
const destinations = [
destination({ endDay: 2, id: id('lisbon'), name: 'Lisbon', position: 0, startDay: 1 }),
destination({ endDay: 4, id: id('montreal'), name: 'Montreal', position: 1, startDay: 3 })
];
- render(
+ await render(
{
/>
);
- expect(screen.getByRole('button', { name: 'Day 1, Taken' }).hasAttribute('data-taken')).toBe(
- true
- );
- expect(screen.queryByRole('button', { name: 'Day 2, Taken' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Day 3, Taken' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Day 4, Taken' })).toBeNull();
+ await expect
+ .element(page.getByRole('button', { name: 'Day 1, Taken' }))
+ .toHaveAttribute('data-taken');
+ await expect.element(page.getByRole('button', { name: 'Day 2, Taken' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Day 3, Taken' })).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Day 4, Taken' })).not.toBeInTheDocument();
});
-test('marks earlier stops fully taken when editing a later stop', () => {
+test('marks earlier stops fully taken when editing a later stop', async () => {
const destinations = [
destination({ endDay: 2, id: id('lisbon'), name: 'Lisbon', position: 0, startDay: 1 }),
destination({ endDay: 4, id: id('montreal'), name: 'Montreal', position: 1, startDay: 3 }),
destination({ endDay: 7, id: id('porto'), name: 'Porto', position: 2, startDay: 5 })
];
- render(
+ await render(
{
/>
);
- expect(screen.getByRole('button', { name: 'Day 1, Taken' }).hasAttribute('data-taken')).toBe(
- true
- );
- expect(screen.getByRole('button', { name: 'Day 2, Taken' }).hasAttribute('data-taken')).toBe(
- true
- );
- expect(screen.getByRole('button', { name: 'Day 3, Taken' }).hasAttribute('data-taken')).toBe(
- true
- );
- expect(screen.queryByRole('button', { name: 'Day 4, Taken' })).toBeNull();
+ await expect
+ .element(page.getByRole('button', { name: 'Day 1, Taken' }))
+ .toHaveAttribute('data-taken');
+ await expect
+ .element(page.getByRole('button', { name: 'Day 2, Taken' }))
+ .toHaveAttribute('data-taken');
+ await expect
+ .element(page.getByRole('button', { name: 'Day 3, Taken' }))
+ .toHaveAttribute('data-taken');
+ await expect.element(page.getByRole('button', { name: 'Day 4, Taken' })).not.toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-destinations/trip-destinations-panel.test.tsx b/apps/web/src/features/trips/trip-destinations/trip-destinations-panel.test.tsx
index 4f15a1f9..054cdf05 100644
--- a/apps/web/src/features/trips/trip-destinations/trip-destinations-panel.test.tsx
+++ b/apps/web/src/features/trips/trip-destinations/trip-destinations-panel.test.tsx
@@ -1,11 +1,10 @@
import type { Id } from '@groam/backend/data-model';
-import { stubPopoverEnvironment } from '@groam/ui/lib/stub-popover-environment';
-import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { act, render } from '@groam/vitest-config/react';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page, userEvent } from 'vite-plus/test/browser';
import type { ItineraryChange } from '@/features/trips/hooks/itinerary-proposal-changes';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import { testIds } from '@/lib/test-ids';
-import { stubMatchMedia } from '@/testing/stub-match-media';
import { plannerActivity } from '@/testing/trip-planner-fixture';
import { TripDestinationsPanel } from './trip-destinations-panel';
@@ -31,7 +30,8 @@ vi.mock('@/features/trips/hooks/use-trip-versions', () => ({
})
}));
-vi.mock('convex/react', () => ({
+vi.mock('convex/react', async (importOriginal) => ({
+ ...(await importOriginal()),
useAction: () => vi.fn().mockResolvedValue({ kind: 'applied' }),
useMutation: () => vi.fn(),
useQuery: () => undefined
@@ -155,71 +155,69 @@ function change(overrides: Partial = {}): ItineraryChange {
}
function dayCard(day: number) {
- return screen.getByRole('region', { name: `Day ${day} schedule` });
+ return page.getByRole('region', { name: `Day ${day} schedule` });
}
-function addOnDay(day: number, kind: 'Activity' | 'Stay' | 'Travel' | 'Destination') {
- fireEvent.keyDown(within(dayCard(day)).getByRole('button', { name: 'Add to day' }), {
- key: 'Enter'
- });
- fireEvent.click(screen.getByRole('menuitem', { name: kind }));
+async function addOnDay(day: number, kind: 'Activity' | 'Stay' | 'Travel' | 'Destination') {
+ await dayCard(day).getByRole('button', { name: 'Add to day' }).click();
+ await page.getByRole('menuitem', { name: kind }).click();
}
-function addActivityOnDay(day: number) {
- addOnDay(day, 'Activity');
+async function addActivityOnDay(day: number) {
+ await addOnDay(day, 'Activity');
}
beforeEach(() => {
vi.clearAllMocks();
- stubPopoverEnvironment();
- stubMatchMedia();
- vi.stubGlobal(
- 'ResizeObserver',
- class {
- disconnect() {}
- observe() {}
- unobserve() {}
- }
- );
versions.proposal = undefined;
versions.proposals = [];
});
-afterEach(cleanup);
-
describe('TripDestinationsPanel day editor', () => {
- test('keeps shared plans read-only', () => {
+ test('keeps shared plans read-only', async () => {
const trip = tripDetail({ proposal: null });
trip.permissions.canEdit = false;
- render();
- expect(screen.getByRole('heading', { name: 'Day 1' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Add a plan' })).toBeNull();
- expect(screen.queryByLabelText('Itinerary editor')).toBeNull();
+ await render();
+ await expect.element(page.getByRole('heading', { name: 'Day 1' })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Add a plan' })).not.toBeInTheDocument();
+ await expect.element(page.getByLabelText('Itinerary editor')).not.toBeInTheDocument();
});
- test('gives an empty idea one clear starting action', () => {
- render(
+ test('gives an empty idea one clear starting action', async () => {
+ await render(
);
- expect(screen.getByText('A place to start. A day to fill.')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Add first destination' }));
+ await expect.element(page.getByText('A place to start. A day to fill.')).toBeInTheDocument();
+ await page.getByRole('button', { name: 'Add first destination' }).click();
expect(handlers.onAddDestination).toHaveBeenCalledOnce();
});
- test('shows every day without a redundant day picker', () => {
- render();
- expect(dayCard(1)).toBeTruthy();
- expect(dayCard(2)).toBeTruthy();
- expect(within(dayCard(1)).getByRole('button', { name: 'Add to day' })).toBeTruthy();
- expect(within(dayCard(2)).getByRole('button', { name: 'Add to day' })).toBeTruthy();
- addActivityOnDay(1);
- expect(screen.getByRole('region', { name: 'Add an activity to Day 1' })).toBeTruthy();
- expect(screen.getByRole('region', { name: 'Day 1 schedule' })).toBeTruthy();
- expect(screen.getByRole('region', { name: 'Day 2 schedule' })).toBeTruthy();
- expect(screen.queryByRole('button', { name: 'Choose a day' })).toBeNull();
- expect(screen.queryByRole('tablist', { name: 'Planning tools' })).toBeNull();
+ test('shows every day without a redundant day picker', async () => {
+ await render(
+
+ );
+ await expect.element(dayCard(1)).toBeInTheDocument();
+ await expect.element(dayCard(2)).toBeInTheDocument();
+ await expect
+ .element(dayCard(1).getByRole('button', { name: 'Add to day' }))
+ .toBeInTheDocument();
+ await expect
+ .element(dayCard(2).getByRole('button', { name: 'Add to day' }))
+ .toBeInTheDocument();
+ await addActivityOnDay(1);
+ await expect
+ .element(page.getByRole('region', { name: 'Add an activity to Day 1' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByRole('region', { name: 'Day 1 schedule' })).toBeInTheDocument();
+ await expect.element(page.getByRole('region', { name: 'Day 2 schedule' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Choose a day' }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('tablist', { name: 'Planning tools' }))
+ .not.toBeInTheDocument();
expect(handlers.updateTrip).not.toHaveBeenCalled();
expect(handlers.updateDestination).not.toHaveBeenCalled();
});
@@ -228,27 +226,25 @@ describe('TripDestinationsPanel day editor', () => {
handlers.addActivity.mockImplementation(
() => new Promise((resolve) => (finishSave = resolve))
);
- render();
+ await render(
+
+ );
- addActivityOnDay(1);
- fireEvent.change(screen.getByTestId(testIds.activityTitle), {
- target: { value: 'Morning market' }
- });
- fireEvent.click(screen.getByTestId(testIds.activitySubmit));
- await waitFor(() => expect(handlers.addActivity).toHaveBeenCalledOnce());
+ await addActivityOnDay(1);
+ await page.getByTestId(testIds.activityTitle).fill('Morning market');
+ await page.getByTestId(testIds.activitySubmit).click();
+ await vi.waitFor(() => expect(handlers.addActivity).toHaveBeenCalledOnce());
- addActivityOnDay(2);
- fireEvent.change(screen.getByTestId(testIds.activityTitle), {
- target: { value: 'Afternoon walk' }
- });
- await act(async () => {
+ await addActivityOnDay(2);
+ await page.getByTestId(testIds.activityTitle).fill('Afternoon walk');
+ await act(() => {
finishSave(true);
});
- expect(screen.getByRole('region', { name: 'Add an activity to Day 2' })).toBeTruthy();
- expect((screen.getByTestId(testIds.activityTitle) as HTMLInputElement).value).toBe(
- 'Afternoon walk'
- );
+ await expect
+ .element(page.getByRole('region', { name: 'Add an activity to Day 2' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByTestId(testIds.activityTitle)).toHaveValue('Afternoon walk');
});
test('locks the destination while an inline activity save is pending', async () => {
@@ -256,7 +252,7 @@ describe('TripDestinationsPanel day editor', () => {
handlers.addActivity.mockImplementation(
() => new Promise((resolve) => (finishSave = resolve))
);
- render(
+ await render(
{
/>
);
- addActivityOnDay(1);
- const destinationSelect = screen.getByRole('combobox', { name: 'Destination' });
- expect(destinationSelect).toHaveProperty('disabled', false);
- fireEvent.change(screen.getByTestId(testIds.activityTitle), {
- target: { value: 'Morning market' }
- });
- fireEvent.click(screen.getByTestId(testIds.activitySubmit));
- await waitFor(() => expect(handlers.addActivity).toHaveBeenCalledOnce());
- expect(destinationSelect).toHaveProperty('disabled', true);
+ await addActivityOnDay(1);
+ const destinationSelect = page.getByRole('combobox', { name: 'Destination' });
+ await expect.element(destinationSelect).toBeEnabled();
+ await page.getByTestId(testIds.activityTitle).fill('Morning market');
+ await page.getByTestId(testIds.activitySubmit).click();
+ await vi.waitFor(() => expect(handlers.addActivity).toHaveBeenCalledOnce());
+ await expect.element(destinationSelect).toBeDisabled();
- await act(async () => {
+ await act(() => {
finishSave(true);
});
});
@@ -293,29 +287,35 @@ describe('TripDestinationsPanel day editor', () => {
media.upload.mockImplementation(
() => new Promise((resolve) => (finishUpload = resolve))
);
- render();
+ await render(
+
+ );
- addActivityOnDay(1);
- fireEvent.click(screen.getByTestId(testIds.activityMoreOptions));
+ await addActivityOnDay(1);
+ await page.getByTestId(testIds.activityMoreOptions).click();
const fileInput = document.querySelector('input[type="file"]');
- expect(fileInput).not.toBeNull();
- fireEvent.change(fileInput as HTMLInputElement, {
- target: { files: [new File(['ticket'], 'ticket.pdf', { type: 'application/pdf' })] }
- });
- const otherDay = within(dayCard(2)).getByRole('button', { name: 'Add to day' });
- await waitFor(() => expect(otherDay).toHaveProperty('disabled', true));
- fireEvent.click(otherDay);
- expect(screen.getByRole('region', { name: 'Add an activity to Day 1' })).toBeTruthy();
+ if (!fileInput) throw new Error('Expected a ticket file input');
+ await userEvent.upload(
+ fileInput,
+ new File(['ticket'], 'ticket.pdf', { type: 'application/pdf' })
+ );
+ const otherDay = dayCard(2).getByRole('button', { name: 'Add to day' });
+ await expect.element(otherDay).toBeDisabled();
+ // A real click waits for an enabled target; force it to prove the disabled day ignores it.
+ await otherDay.click({ force: true });
+ await expect
+ .element(page.getByRole('region', { name: 'Add an activity to Day 1' }))
+ .toBeInTheDocument();
- await act(async () => {
+ await act(() => {
finishUpload('media-ticket');
});
- expect(screen.getByText('ticket.pdf')).toBeTruthy();
- expect(otherDay).toHaveProperty('disabled', false);
+ await expect.element(page.getByText('ticket.pdf')).toBeInTheDocument();
+ await expect.element(otherDay).toBeEnabled();
});
- test('preserves occupied-day constraints in the selected route stop', () => {
- render(
+ test('preserves occupied-day constraints in the selected route stop', async () => {
+ await render(
{
})}
/>
);
- fireEvent.click(screen.getByRole('button', { name: 'Manage Porto for Day 3' }));
- expect(screen.getByRole('button', { name: 'Day 1, Taken' }).hasAttribute('data-taken')).toBe(
- true
- );
+ await page.getByRole('button', { name: 'Manage Porto for Day 3' }).click();
+ await page.getByText('Schedule & notes').click();
+ await expect
+ .element(page.getByRole('button', { name: 'Day 1, Taken' }))
+ .toHaveAttribute('data-taken');
});
- test('preserves change highlights and removed items in the route summary', () => {
+ test('preserves change highlights and removed items in the route summary', async () => {
versions.proposals = [{ id: 'proposal-1', workingTripId: 'working-1' }];
versions.proposal = {
changes: [
@@ -348,7 +349,7 @@ describe('TripDestinationsPanel day editor', () => {
conflicts: [],
sourceChanged: false
};
- render(
+ await render(
{
expect(document.querySelector('[data-proposal-change="modified"]')).toBeTruthy();
expect(document.querySelector('[data-proposal-change="added"]')).toBeTruthy();
expect(document.querySelector('[data-proposal-change="removed"]')).toBeTruthy();
- expect(screen.getByText('Faro')).toBeTruthy();
+ await expect.element(page.getByText('Faro')).toBeInTheDocument();
});
test('adds an activity on its day through the existing action', async () => {
handlers.addActivity.mockResolvedValue(true);
- render();
- addActivityOnDay(2);
- fireEvent.change(screen.getByPlaceholderText('What are we doing?'), {
- target: { value: 'Museum visit' }
- });
- fireEvent.click(screen.getByTestId(testIds.activitySubmit));
- await waitFor(() =>
+ await render(
+
+ );
+ await addActivityOnDay(2);
+ await page.getByPlaceholder('What are we doing?').fill('Museum visit');
+ await page.getByTestId(testIds.activitySubmit).click();
+ await vi.waitFor(() =>
expect(handlers.addActivity).toHaveBeenCalledWith(
'destination-lisbon',
expect.objectContaining({
@@ -382,31 +383,31 @@ describe('TripDestinationsPanel day editor', () => {
})
)
);
- await waitFor(() =>
- expect(screen.queryByRole('region', { name: 'Add an activity to Day 2' })).toBeNull()
- );
+ await expect
+ .element(page.getByRole('region', { name: 'Add an activity to Day 2' }))
+ .not.toBeInTheDocument();
});
- test('allows plans before a trip duration has been chosen', () => {
- render(
+ test('allows plans before a trip duration has been chosen', async () => {
+ await render(
);
- addActivityOnDay(1);
- expect(screen.getByTestId(testIds.activitySubmit)).toBeTruthy();
+ await addActivityOnDay(1);
+ await expect.element(page.getByTestId(testIds.activitySubmit)).toBeInTheDocument();
});
test('starts a stay on the chosen day using the shared stay form', async () => {
handlers.addStay.mockResolvedValue(true);
- render();
- addOnDay(2, 'Stay');
- expect(screen.getByRole('region', { name: 'Add stay' })).toBeTruthy();
- fireEvent.change(screen.getByRole('textbox', { name: 'Property or stay name' }), {
- target: { value: 'Riverside rooms' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Save stay' }));
- await waitFor(() =>
+ await render(
+
+ );
+ await addOnDay(2, 'Stay');
+ await expect.element(page.getByRole('region', { name: 'Add stay' })).toBeInTheDocument();
+ await page.getByRole('textbox', { name: 'Property or stay name' }).fill('Riverside rooms');
+ await page.getByRole('button', { name: 'Save stay' }).click();
+ await vi.waitFor(() =>
expect(handlers.addStay).toHaveBeenCalledWith(
'destination-lisbon',
expect.objectContaining({
@@ -418,41 +419,41 @@ describe('TripDestinationsPanel day editor', () => {
});
test('adds travel for a specific connection from the day card', async () => {
handlers.setBoundaryTransfer.mockResolvedValue(true);
- render();
- addOnDay(1, 'Travel');
- fireEvent.click(screen.getByRole('button', { name: 'Getting to Lisbon' }));
- expect(screen.getByRole('region', { name: 'Add travel' })).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Save travel' }));
- await waitFor(() =>
+ await render(
+
+ );
+ await addOnDay(1, 'Travel');
+ await page.getByRole('button', { name: 'Getting to Lisbon' }).click();
+ await expect.element(page.getByRole('region', { name: 'Add travel' })).toBeInTheDocument();
+ await page.getByRole('button', { name: 'Save travel' }).click();
+ await vi.waitFor(() =>
expect(handlers.setBoundaryTransfer).toHaveBeenCalledWith(
'arrival',
expect.objectContaining({ mode: 'train' })
)
);
});
- test('offers destination creation from each day menu', () => {
- render();
- addOnDay(2, 'Destination');
+ test('offers destination creation from each day menu', async () => {
+ await render(
+
+ );
+ await addOnDay(2, 'Destination');
expect(handlers.onAddDestination).toHaveBeenCalledOnce();
});
test('opens an existing plan and saves its identity without creating a duplicate', async () => {
handlers.updateActivity.mockResolvedValue(true);
- render(
+ await render(
);
- fireEvent.click(screen.getByRole('button', { name: 'Edit Explore the tile museum' }));
- expect((screen.getByLabelText('Start time (optional)') as HTMLInputElement).value).toBe(
- '10:00'
- );
- fireEvent.change(screen.getByPlaceholderText('What are we doing?'), {
- target: { value: 'Museum and café' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
- await waitFor(() =>
+ await page.getByRole('button', { name: 'Edit Explore the tile museum' }).click();
+ await expect.element(page.getByLabelText('Start time (optional)')).toHaveValue('10:00');
+ await page.getByPlaceholder('What are we doing?').fill('Museum and café');
+ await page.getByRole('button', { name: 'Save changes' }).click();
+ await vi.waitFor(() =>
expect(handlers.updateActivity).toHaveBeenCalledWith(
'museum',
expect.objectContaining({ title: 'Museum and café' })
@@ -461,17 +462,17 @@ describe('TripDestinationsPanel day editor', () => {
expect(handlers.addActivity).not.toHaveBeenCalled();
});
test('can plan a trip length without choosing calendar dates', async () => {
- render(
+ await render(
);
- fireEvent.click(screen.getByRole('button', { name: 'Set trip length' }));
- fireEvent.change(screen.getByLabelText('Trip length in days'), { target: { value: '3' } });
- fireEvent.click(screen.getByRole('button', { name: 'Save trip length' }));
- await waitFor(() =>
+ await page.getByRole('button', { name: 'Set trip length' }).click();
+ await page.getByLabelText('Trip length in days').fill('3');
+ await page.getByRole('button', { name: 'Save trip length' }).click();
+ await vi.waitFor(() =>
expect(handlers.updateTrip).toHaveBeenCalledWith(
expect.objectContaining({ duration: { totalDays: 3 } })
)
@@ -479,21 +480,21 @@ describe('TripDestinationsPanel day editor', () => {
});
test('requires explicit confirmation inside the panel before deleting a plan', async () => {
handlers.removeActivity.mockResolvedValue(true);
- render(
+ await render(
);
- fireEvent.click(screen.getByRole('button', { name: 'Edit Explore the tile museum' }));
- fireEvent.click(screen.getByRole('button', { name: 'Remove Explore the tile museum' }));
+ await page.getByRole('button', { name: 'Edit Explore the tile museum' }).click();
+ await page.getByRole('button', { name: 'Remove Explore the tile museum' }).click();
expect(handlers.removeActivity).not.toHaveBeenCalled();
- fireEvent.click(screen.getByRole('button', { name: 'Keep plan' }));
+ await page.getByRole('button', { name: 'Keep plan' }).click();
expect(handlers.removeActivity).not.toHaveBeenCalled();
- fireEvent.click(screen.getByRole('button', { name: 'Remove Explore the tile museum' }));
- fireEvent.click(screen.getByRole('button', { name: 'Remove plan' }));
- await waitFor(() => expect(handlers.removeActivity).toHaveBeenCalledWith('museum'));
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await page.getByRole('button', { name: 'Remove Explore the tile museum' }).click();
+ await page.getByRole('button', { name: 'Remove plan' }).click();
+ await vi.waitFor(() => expect(handlers.removeActivity).toHaveBeenCalledWith('museum'));
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/trips/trip-destinations/trip-route-summary.test.tsx b/apps/web/src/features/trips/trip-destinations/trip-route-summary.test.tsx
index 4a1b00b5..1f024fb8 100644
--- a/apps/web/src/features/trips/trip-destinations/trip-route-summary.test.tsx
+++ b/apps/web/src/features/trips/trip-destinations/trip-route-summary.test.tsx
@@ -1,15 +1,13 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
-import { stubMatchMedia } from '@/testing/stub-match-media';
import { TripRouteSummary } from './trip-route-summary';
vi.mock('@/features/trips/trip-destinations/trip-destinations-map', () => ({
TripDestinationsMap: () =>
}));
-afterEach(cleanup);
-
const destinations = [
{
id: 'lisbon',
@@ -20,21 +18,20 @@ const destinations = [
}
] as unknown as TripDetail['destinations'];
-test('only loads the map when requested and allows closing it', () => {
- render();
- expect(screen.queryByLabelText('Interactive route map')).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: 'Show route map' }));
- expect(screen.getByLabelText('Interactive route map')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Hide route map' }).getAttribute('aria-expanded')).toBe(
- 'true'
- );
- fireEvent.click(screen.getByRole('button', { name: 'Hide route map' }));
- expect(screen.queryByLabelText('Interactive route map')).toBeNull();
+test('only loads the map when requested and allows closing it', async () => {
+ await render();
+ await expect.element(page.getByLabelText('Interactive route map')).not.toBeInTheDocument();
+ await page.getByRole('button', { name: 'Show route map' }).click();
+ await expect.element(page.getByLabelText('Interactive route map')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Hide route map' }))
+ .toHaveAttribute('aria-expanded', 'true');
+ await page.getByRole('button', { name: 'Hide route map' }).click();
+ await expect.element(page.getByLabelText('Interactive route map')).not.toBeInTheDocument();
});
-test('moves keyboard focus and scrolls to the chosen destination', () => {
- stubMatchMedia();
- render(
+test('moves keyboard focus and scrolls to the chosen destination', async () => {
+ await render(
<>
Lisbon plans
@@ -42,10 +39,9 @@ test('moves keyboard focus and scrolls to the chosen destination', () => {
>
);
- const destination = screen.getByText('Lisbon plans');
- const scrollIntoView = vi.fn();
- destination.scrollIntoView = scrollIntoView;
- fireEvent.click(screen.getByRole('button', { name: 'Jump to Lisbon' }));
- expect(document.activeElement).toBe(destination);
+ const destination = page.getByText('Lisbon plans');
+ const scrollIntoView = vi.spyOn(destination.element(), 'scrollIntoView');
+ await page.getByRole('button', { name: 'Jump to Lisbon' }).click();
+ await expect.element(destination).toHaveFocus();
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
});
diff --git a/apps/web/src/features/trips/trip-detail/shared-trip-banner.test.tsx b/apps/web/src/features/trips/trip-detail/shared-trip-banner.test.tsx
index 3df09b1c..523dc5e6 100644
--- a/apps/web/src/features/trips/trip-detail/shared-trip-banner.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/shared-trip-banner.test.tsx
@@ -1,25 +1,24 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { SharedTripBanner } from './shared-trip-banner';
-afterEach(cleanup);
-
-test('explains that the shared trip is read-only and offers New idea', () => {
+test('explains that the shared trip is read-only and offers New idea', async () => {
const onStartIdea = vi.fn();
- render();
+ await render();
- const banner = screen.getByTestId('shared-trip-banner');
+ const banner = page.getByTestId('shared-trip-banner').element();
expect(banner.className).toContain('bg-muted/30');
expect(banner.className).toContain('mt-4');
- expect(screen.getByText('Shared trip — read-only')).toBeTruthy();
- expect(screen.getByText(/Changes are made in ideas/u)).toBeTruthy();
- fireEvent.click(screen.getByTestId('shared-trip-new-idea'));
+ await expect.element(page.getByText('Shared trip — read-only')).toBeInTheDocument();
+ await expect.element(page.getByText(/Changes are made in ideas/u)).toBeInTheDocument();
+ await page.getByTestId('shared-trip-new-idea').click();
expect(onStartIdea).toHaveBeenCalledOnce();
});
-test('offers continue draft when the viewer already has one', () => {
+test('offers continue draft when the viewer already has one', async () => {
const onContinueDraft = vi.fn();
- render(
+ await render(
{
/>
);
- fireEvent.click(screen.getByTestId('shared-trip-continue-draft'));
+ await page.getByTestId('shared-trip-continue-draft').click();
expect(onContinueDraft).toHaveBeenCalledOnce();
- expect(screen.getByRole('button', { name: 'Continue your draft: Add a stop' })).toBeTruthy();
+ await expect
+ .element(page.getByRole('button', { name: 'Continue your draft: Add a stop' }))
+ .toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-detail/trip-detail-view.test.tsx b/apps/web/src/features/trips/trip-detail/trip-detail-view.test.tsx
index 7b8625f7..14e39185 100644
--- a/apps/web/src/features/trips/trip-detail/trip-detail-view.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/trip-detail-view.test.tsx
@@ -1,7 +1,8 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
+import { render } from '@groam/vitest-config/react';
import type { ReactNode } from 'react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import type { TripSection } from '@/features/trips/trip-sections';
import { TripDetailView } from './trip-detail-view';
@@ -11,7 +12,8 @@ const state = vi.hoisted(() => ({
trip: undefined as TripDetail | undefined
}));
-vi.mock('@tanstack/react-router', () => ({
+vi.mock('@tanstack/react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
Navigate: ({ to }: { to: string }) => navigate:{to}
,
useNavigate: () => vi.fn()
@@ -29,7 +31,8 @@ vi.mock('@/features/trips/hooks/use-trips', () => ({
})
}));
-vi.mock('@/features/trips/hooks/use-trip-versions', () => ({
+vi.mock('@/features/trips/hooks/use-trip-versions', async (importOriginal) => ({
+ ...(await importOriginal()),
useTripVersions: () => ({
createVersion: vi.fn(),
proposals: [
@@ -136,7 +139,7 @@ function tripDetail(overrides: Partial = {}): TripDetail {
} as TripDetail;
}
-function renderTrip(section: TripSection, trip: TripDetail = tripDetail()) {
+async function renderTrip(section: TripSection, trip: TripDetail = tripDetail()) {
state.exists = true;
state.trip = trip;
return render();
@@ -147,33 +150,37 @@ beforeEach(() => {
state.trip = undefined;
});
-afterEach(cleanup);
-
describe('TripDetailView idea workspace', () => {
test.each(['overview', 'itinerary', 'ideas', 'activity'] as const)(
'redirects a working-trip idea to the nested clone on %s',
- (section) => {
- renderTrip(section);
+ async (section) => {
+ await renderTrip(section);
- expect(screen.getByText('navigate:/trips/$tripId/ideas/$proposalId/$view')).toBeTruthy();
- expect(screen.queryByLabelText('Idea workspace')).toBeNull();
+ await expect
+ .element(page.getByText('navigate:/trips/$tripId/ideas/$proposalId/$view'))
+ .toBeInTheDocument();
+ await expect.element(page.getByLabelText('Idea workspace')).not.toBeInTheDocument();
}
);
- test('does not redirect a shared trip', () => {
- renderTrip('overview', tripDetail({ id: 'source-1' as Id<'trips'>, proposal: null }));
-
- expect(screen.queryByText('navigate:/trips/$tripId/ideas/$proposalId/$view')).toBeNull();
- expect(screen.queryByLabelText('Idea workspace')).toBeNull();
- expect(screen.queryByRole('button', { name: 'Open shared trip' })).toBeNull();
- expect(screen.getByText('Overview section')).toBeTruthy();
+ test('does not redirect a shared trip', async () => {
+ await renderTrip('overview', tripDetail({ id: 'source-1' as Id<'trips'>, proposal: null }));
+
+ await expect
+ .element(page.getByText('navigate:/trips/$tripId/ideas/$proposalId/$view'))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByLabelText('Idea workspace')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Open shared trip' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByText('Overview section')).toBeInTheDocument();
});
});
test.each(['overview', 'itinerary'] as const)(
'renders the full viewing page at %s with one creation action',
- (section) => {
- renderTrip(
+ async (section) => {
+ await renderTrip(
section,
tripDetail({
id: 'source-1' as Id<'trips'>,
@@ -188,10 +195,14 @@ test.each(['overview', 'itinerary'] as const)(
}
})
);
- expect(screen.getByRole('region', { name: 'Trip at a glance' })).toBeTruthy();
- expect(screen.getByText('Itinerary section')).toBeTruthy();
- expect(screen.getAllByRole('button', { name: 'New idea' })).toHaveLength(1);
- expect(screen.queryByTestId('trip-section-itinerary')).toBeNull();
- expect(screen.queryByRole('button', { name: 'Edit details' })).toBeNull();
+ await expect
+ .element(page.getByRole('region', { name: 'Trip at a glance' }))
+ .toBeInTheDocument();
+ await expect.element(page.getByText('Itinerary section')).toBeInTheDocument();
+ expect(page.getByRole('button', { name: 'New idea' }).elements()).toHaveLength(1);
+ await expect.element(page.getByTestId('trip-section-itinerary')).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Edit details' }))
+ .not.toBeInTheDocument();
}
);
diff --git a/apps/web/src/features/trips/trip-detail/trip-loading.test.tsx b/apps/web/src/features/trips/trip-detail/trip-loading.test.tsx
index 97562800..fea1457d 100644
--- a/apps/web/src/features/trips/trip-detail/trip-loading.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/trip-loading.test.tsx
@@ -1,15 +1,14 @@
import { PageCrumbNav } from '@groam/ui/components/page-crumb-nav';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { TripOverview } from '@/features/trips/trip-overview/trip-overview';
import { TripPageHero } from './trip-page-hero';
import { TripPageShell } from './trip-page-shell';
import { TripSectionNav } from './trip-section-nav';
-afterEach(cleanup);
-
-test('trip notice sits below the header and tab navigation', () => {
- const { container } = render(
+test('trip notice sits below the header and tab navigation', async () => {
+ const { container } = await render(
Notice}
body={Trip content
}
@@ -21,13 +20,13 @@ test('trip notice sits below the header and tab navigation', () => {
const content = container.querySelector('[data-slot="shell-content"]');
const header = content?.querySelector('header');
- const notice = screen.getByTestId('trip-notice');
+ const notice = page.getByTestId('trip-notice').element();
expect(header?.contains(notice)).toBe(false);
expect(notice.previousElementSibling?.textContent).toBe('Trip sections');
});
-test('trip page uses one shared loading surface without rendering its unknown content', () => {
- const { container } = render(
+test('trip page uses one shared loading surface without rendering its unknown content', async () => {
+ const { container } = await render(
}
crumb={
@@ -39,8 +38,10 @@ test('trip page uses one shared loading surface without rendering its unknown co
/>
);
- expect(screen.getByRole('status', { name: 'Loading trip…' })).toBeTruthy();
+ await expect.element(page.getByRole('status', { name: 'Loading trip…' })).toBeInTheDocument();
expect(container.querySelectorAll('[data-slot="page-loading"]')).toHaveLength(1);
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull();
- expect(screen.queryByRole('navigation', { name: 'Trip sections' })).toBeNull();
+ await expect
+ .element(page.getByRole('navigation', { name: 'Trip sections' }))
+ .not.toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-detail/trip-navigation-actions.test.tsx b/apps/web/src/features/trips/trip-detail/trip-navigation-actions.test.tsx
index 8ebc8b43..0bcdb04b 100644
--- a/apps/web/src/features/trips/trip-detail/trip-navigation-actions.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/trip-navigation-actions.test.tsx
@@ -1,17 +1,11 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import type { TripState } from './trip-detail-types';
import { TripNavigationActions } from './trip-navigation-actions';
-vi.mock('@groam/ui/components/dropdown-menu', async () => {
- const { dropdownMenuTestMock } = await import('@/lib/test-mocks/dropdown-menu');
- return dropdownMenuTestMock;
-});
-
-afterEach(cleanup);
-
function trip(overrides: Partial = {}): TripDetail {
return {
id: 'trip-1' as Id<'trips'>,
@@ -41,9 +35,9 @@ function tripState(): TripState {
} as unknown as TripState;
}
-test('puts Delete draft in the actions menu', () => {
+test('puts Delete draft in the actions menu', async () => {
const onCloseIdea = vi.fn();
- render(
+ await render(
{
/>
);
- fireEvent.click(screen.getByTestId('trip-actions'));
- fireEvent.click(screen.getByTestId('close-idea-header'));
+ await page.getByTestId('trip-actions').click();
+ await page.getByTestId('close-idea-header').click();
expect(onCloseIdea).toHaveBeenCalledOnce();
});
-test('puts Close without applying in the actions menu for a submitted idea', () => {
+test('puts Close without applying in the actions menu for a submitted idea', async () => {
const onCloseIdea = vi.fn();
- render(
+ await render(
);
- fireEvent.click(screen.getByTestId('trip-actions'));
- expect(screen.getByText('Close without applying')).toBeTruthy();
+ await page.getByTestId('trip-actions').click();
+ await expect.element(page.getByText('Close without applying')).toBeInTheDocument();
});
diff --git a/apps/web/src/features/trips/trip-detail/trip-page-hero.test.tsx b/apps/web/src/features/trips/trip-detail/trip-page-hero.test.tsx
index 5acef097..5dfd2ef9 100644
--- a/apps/web/src/features/trips/trip-detail/trip-page-hero.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/trip-page-hero.test.tsx
@@ -1,6 +1,7 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import { TripPageHero } from './trip-page-hero';
@@ -8,7 +9,8 @@ vi.mock('@/features/trips/hooks/use-trip-travelers', () => ({
useTripTravelers: () => ({ goingCount: 3 })
}));
-vi.mock('@/features/trips/hooks/use-trip-versions', () => ({
+vi.mock('@/features/trips/hooks/use-trip-versions', async (importOriginal) => ({
+ ...(await importOriginal()),
useTripVersions: () => ({ proposals: [] })
}));
@@ -60,7 +62,7 @@ function tripDetail(overrides: Partial = {}): TripDetail {
} as TripDetail;
}
-function renderHero(
+async function renderHero(
trip: TripDetail = tripDetail(),
retryCover: () => Promise = vi.fn().mockResolvedValue(true)
) {
@@ -69,48 +71,47 @@ function renderHero(
);
}
-afterEach(cleanup);
-
describe('TripPageHero banner layout', () => {
- test('keeps its text inset so only the surrounding band goes full bleed', () => {
- renderHero();
+ test('keeps its text inset so only the surrounding band goes full bleed', async () => {
+ await renderHero();
- const row = screen.getByTestId('trip-heading').closest('div')?.parentElement?.parentElement;
+ const row = page.getByTestId('trip-heading').element().closest('div')
+ ?.parentElement?.parentElement;
expect(row?.className).toContain('px-4');
expect(row?.className).not.toContain('-mx-');
expect(row?.className).not.toContain('w-screen');
});
- test('separates the title, destination, and meta line', () => {
- renderHero();
+ test('separates the title, destination, and meta line', async () => {
+ await renderHero();
- expect(screen.getByTestId('trip-destination').className).toContain('mt-1');
- expect(screen.getByTestId('trip-role').className).toContain('mt-1');
+ await expect.element(page.getByTestId('trip-destination')).toHaveClass('mt-1');
+ await expect.element(page.getByTestId('trip-role')).toHaveClass('mt-1');
});
});
describe('TripPageHero cover refresh', () => {
test('lets an editor find another automatic cover from the thumbnail', async () => {
const retryCover = vi.fn().mockResolvedValue(true);
- renderHero(tripDetail(), retryCover);
+ await renderHero(tripDetail(), retryCover);
- const button = screen.getByRole('button', { name: 'Find another image' });
- expect(button).toBeTruthy();
- fireEvent.click(button);
+ await page.getByRole('button', { name: 'Find another image' }).click();
expect(retryCover).toHaveBeenCalledOnce();
- expect(await screen.findByRole('button', { name: 'Find another image' })).toBeTruthy();
+ await expect
+ .element(page.getByRole('button', { name: 'Find another image' }))
+ .toBeInTheDocument();
});
- test('shows a loading state on the thumbnail while a new cover is pending', () => {
- renderHero(tripDetail({ coverStatus: 'pending' }));
+ test('shows a loading state on the thumbnail while a new cover is pending', async () => {
+ await renderHero(tripDetail({ coverStatus: 'pending' }));
- const button = screen.getByRole('button', { name: 'Finding another image' });
- expect((button as HTMLButtonElement).disabled).toBe(true);
- expect(button.getAttribute('aria-busy')).toBe('true');
- expect(screen.getByLabelText('Loading')).toBeTruthy();
- expect(button.parentElement?.className).toContain('opacity-100');
- expect(button.parentElement?.className).toContain('motion-reduce:transition-none');
+ const button = page.getByRole('button', { name: 'Finding another image' });
+ await expect.element(button).toBeDisabled();
+ await expect.element(button).toHaveAttribute('aria-busy', 'true');
+ await expect.element(page.getByLabelText('Loading')).toBeInTheDocument();
+ expect(button.element().parentElement?.className).toContain('opacity-100');
+ expect(button.element().parentElement?.className).toContain('motion-reduce:transition-none');
});
test('disables the control while the refetch request is in flight', async () => {
@@ -121,29 +122,27 @@ describe('TripPageHero cover refresh', () => {
resolveRetry = resolve;
})
);
- renderHero(tripDetail(), retryCover);
+ await renderHero(tripDetail(), retryCover);
- fireEvent.click(screen.getByRole('button', { name: 'Find another image' }));
+ await page.getByRole('button', { name: 'Find another image' }).click();
- const busy = await screen.findByRole('button', { name: 'Finding another image' });
- expect((busy as HTMLButtonElement).disabled).toBe(true);
- expect(screen.getByLabelText('Loading')).toBeTruthy();
+ await expect
+ .element(page.getByRole('button', { name: 'Finding another image' }))
+ .toBeDisabled();
+ await expect.element(page.getByLabelText('Loading')).toBeInTheDocument();
resolveRetry(true);
- expect(
- ((await screen.findByRole('button', { name: 'Find another image' })) as HTMLButtonElement)
- .disabled
- ).toBe(false);
+ await expect.element(page.getByRole('button', { name: 'Find another image' })).toBeEnabled();
});
- test('labels a failed automatic cover as a retry', () => {
- renderHero(tripDetail({ coverAttribution: null, coverStatus: 'failed', coverUrl: null }));
+ test('labels a failed automatic cover as a retry', async () => {
+ await renderHero(tripDetail({ coverAttribution: null, coverStatus: 'failed', coverUrl: null }));
- expect(screen.getByRole('button', { name: 'Retry cover' })).toBeTruthy();
+ await expect.element(page.getByRole('button', { name: 'Retry cover' })).toBeInTheDocument();
});
- test('hides the refetch control when the traveler cannot change the cover', () => {
- renderHero(
+ test('hides the refetch control when the traveler cannot change the cover', async () => {
+ await renderHero(
tripDetail({
permissions: {
canArchive: false,
@@ -156,14 +155,20 @@ describe('TripPageHero cover refresh', () => {
})
);
- expect(screen.queryByRole('button', { name: 'Find another image' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Finding another image' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Retry cover' })).toBeNull();
+ await expect
+ .element(page.getByRole('button', { name: 'Find another image' }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Finding another image' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Retry cover' })).not.toBeInTheDocument();
});
- test('does not offer refetch on an uploaded cover', () => {
- renderHero(tripDetail({ coverAttribution: null, coverStatus: 'ready' }));
+ test('does not offer refetch on an uploaded cover', async () => {
+ await renderHero(tripDetail({ coverAttribution: null, coverStatus: 'ready' }));
- expect(screen.queryByRole('button', { name: 'Find another image' })).toBeNull();
+ await expect
+ .element(page.getByRole('button', { name: 'Find another image' }))
+ .not.toBeInTheDocument();
});
});
diff --git a/apps/web/src/features/trips/trip-detail/trip-page-shell.test.tsx b/apps/web/src/features/trips/trip-detail/trip-page-shell.test.tsx
new file mode 100644
index 00000000..4eece643
--- /dev/null
+++ b/apps/web/src/features/trips/trip-detail/trip-page-shell.test.tsx
@@ -0,0 +1,229 @@
+import Shell from '@groam/ui/components/shell/client';
+import { SidebarProvider } from '@groam/ui/components/sidebar';
+import { render } from '@groam/vitest-config/react';
+import { useViewport } from '@groam/vitest-config/viewport';
+import type { ReactNode } from 'react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
+import { ChangeSummary } from '@/features/trips/trip-versions/change-summary';
+import { StatefulItineraryEditor } from '@/testing/itinerary-editor-fixture';
+import { itineraryTrip } from '@/testing/itinerary-fixture';
+import { TripPageShell } from './trip-page-shell';
+
+vi.mock('@/features/media/hooks/use-media-upload', () => ({
+ useMediaUpload: () => vi.fn()
+}));
+
+type Tab = 'compare' | 'itinerary' | 'overview';
+
+function TwoColumnBody({ children }: { children?: ReactNode }) {
+ return (
+
+ {children ?? Main column
}
+
+ Supporting column
+
+
+ );
+}
+
+const bodies = {
+ compare: () => (
+
+
+
+ ),
+ itinerary: () => (
+
+ ),
+ overview: () =>
+} satisfies Record ReactNode>;
+
+/** The trip page as the workspace shell frames it, showing the body of one tab. */
+function TripPage({ tab }: { tab: Tab }) {
+ return (
+
+
+ Trips}
+ hero={Coast week
}
+ nav={}
+ />
+
+
+ );
+}
+
+function required(selector: string, root: ParentNode = document) {
+ const element = root.querySelector(selector);
+ if (!element) throw new Error(`Expected ${selector}`);
+ return element;
+}
+
+/** Let the browser resolve semantic tokens and keywords the way it paints them. */
+function resolvedBackground(value: string) {
+ const probe = document.body.appendChild(document.createElement('span'));
+ probe.style.backgroundColor = value;
+ const resolved = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return resolved;
+}
+
+/** The painted canvas behind the page body, looking through transparent wrappers. */
+function pageCanvas() {
+ const body = required('[data-slot="shell-page-body"]');
+ const clear = resolvedBackground('transparent');
+ let canvas: Element | null = body;
+ while (canvas && getComputedStyle(canvas).backgroundColor === clear)
+ canvas = canvas.parentElement;
+ const frame = body.getBoundingClientRect();
+ return {
+ background: canvas ? getComputedStyle(canvas).backgroundColor : clear,
+ overflow: body.scrollWidth > body.clientWidth,
+ padding: getComputedStyle(body).padding,
+ width: Math.round(frame.width)
+ };
+}
+
+function twoColumnLayout() {
+ const body = required('[data-slot="shell-page-body"]');
+ const grid = required('[data-slot="shell-two-columns"]', body);
+ const frame = body.getBoundingClientRect();
+ const primary = required('[data-slot="shell-left-column"]', grid).getBoundingClientRect();
+ const secondary = required('[data-slot="shell-right-column"]', grid).getBoundingClientRect();
+ return {
+ columns: getComputedStyle(grid).gridTemplateColumns,
+ gap: getComputedStyle(grid).gap,
+ leftInset: Math.round(primary.left - frame.left),
+ padding: getComputedStyle(body).padding,
+ rightWidth: Math.round(secondary.width),
+ stacked: secondary.top >= primary.bottom,
+ topInset: Math.round(primary.top - frame.top)
+ };
+}
+
+const pageOverflows = () =>
+ document.documentElement.scrollWidth > document.documentElement.clientWidth;
+
+beforeEach(() => {
+ document.documentElement.classList.remove('dark');
+});
+
+afterEach(() => {
+ document.documentElement.classList.remove('dark');
+});
+
+describe('supporting column', () => {
+ // Trip pages size the supporting column from the trip workspace container (18rem = 288px).
+ test.each([
+ [1440, false],
+ [1024, false],
+ [1023, true],
+ [390, true]
+ ])('at %ipx wide the column stacked is %s', async (width, stacked) => {
+ await useViewport(width, 1000);
+ await render();
+ await expect.element(page.getByText('Supporting column')).toBeVisible();
+ const layout = twoColumnLayout();
+ expect(layout.stacked).toBe(stacked);
+ if (!stacked) expect(layout.rightWidth).toBe(288);
+ expect(pageCanvas().overflow).toBe(false);
+ expect(pageOverflows()).toBe(false);
+ });
+
+ test.each([1440, 1024, 390])(
+ 'the comparison tab lays out exactly like the overview at %ipx',
+ async (width) => {
+ await useViewport(width, 1000);
+ const view = await render();
+ await expect.element(page.getByText('Supporting column')).toBeVisible();
+ const overview = { canvas: pageCanvas(), layout: twoColumnLayout() };
+ await view.rerender();
+ await expect.element(page.getByText('Museum visit')).toBeVisible();
+ expect({ canvas: pageCanvas(), layout: twoColumnLayout() }).toEqual(overview);
+ }
+ );
+});
+
+describe('itinerary tab', () => {
+ test.each([1440, 1024, 390])(
+ 'uses the full body width without a supporting column at %ipx',
+ async (width) => {
+ await useViewport(width, 1000);
+ await render();
+ await expect.element(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible();
+ await expect.element(page.getByText('Supporting column')).not.toBeInTheDocument();
+ const body = required('[data-slot="shell-page-body"]');
+ const style = getComputedStyle(body);
+ const available = Math.round(
+ body.clientWidth -
+ Number.parseFloat(style.paddingLeft) -
+ Number.parseFloat(style.paddingRight)
+ );
+ const section = page.getByRole('region', { name: 'Itinerary editor' }).element();
+ expect(Math.round(section.getBoundingClientRect().width)).toBe(available);
+ expect(pageOverflows()).toBe(false);
+ }
+ );
+
+ test('shares the overview canvas', async () => {
+ const view = await render();
+ await expect.element(page.getByText('Supporting column')).toBeVisible();
+ const overview = pageCanvas();
+ await view.rerender();
+ await expect.element(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible();
+ expect(pageCanvas()).toEqual(overview);
+ });
+});
+
+describe('page canvas', () => {
+ test.each([
+ ['light', false, 1440],
+ ['light', false, 390],
+ ['dark', true, 1440],
+ ['dark', true, 390]
+ ])('every tab paints the canvas token in %s mode at %ipx', async (_mode, dark, width) => {
+ document.documentElement.classList.toggle('dark', dark);
+ await useViewport(width, 1000);
+ const view = await render();
+ await expect.element(page.getByText('Supporting column')).toBeVisible();
+ const overview = pageCanvas();
+ expect(overview.overflow).toBe(false);
+ expect(overview.background).toBe(resolvedBackground('var(--canvas)'));
+ // The workspace wrapper stays transparent so the canvas shows through.
+ expect(getComputedStyle(required('[data-slot="sidebar-wrapper"]')).backgroundColor).toBe(
+ resolvedBackground('transparent')
+ );
+
+ await view.rerender();
+ await expect.element(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible();
+ expect(pageCanvas()).toEqual(overview);
+ await view.rerender();
+ await expect.element(page.getByText('Museum visit')).toBeVisible();
+ expect(pageCanvas()).toEqual(overview);
+ });
+
+ test('the dark canvas differs from the light canvas', async () => {
+ const view = await render();
+ await expect.element(page.getByText('Supporting column')).toBeVisible();
+ const light = pageCanvas().background;
+ document.documentElement.classList.add('dark');
+ await view.rerender();
+ expect(pageCanvas().background).not.toBe(light);
+ });
+});
diff --git a/apps/web/src/features/trips/trip-detail/trip-section-nav.test.tsx b/apps/web/src/features/trips/trip-detail/trip-section-nav.test.tsx
index ddb84826..0ea06de2 100644
--- a/apps/web/src/features/trips/trip-detail/trip-section-nav.test.tsx
+++ b/apps/web/src/features/trips/trip-detail/trip-section-nav.test.tsx
@@ -1,6 +1,7 @@
import type { Id } from '@groam/backend/data-model';
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import type { TripDetail } from '@/features/trips/hooks/use-trips';
import { TripSectionNav } from './trip-section-nav';
@@ -20,22 +21,25 @@ const trip = {
proposal: null
} as unknown as TripDetail;
-afterEach(cleanup);
+test('runs the tab hairline edge to edge while the tabs stay inset', async () => {
+ await render();
-test('runs the tab hairline edge to edge while the tabs stay inset', () => {
- render();
-
- const nav = screen.getByRole('navigation', { name: 'Trip sections' });
+ const nav = page.getByRole('navigation', { name: 'Trip sections' }).element();
expect(nav.className).toContain('border-b');
expect(nav.className).toContain('w-full');
expect(nav.className).not.toContain('px-4');
- const tabs = screen.getByTestId('trip-section-overview').parentElement;
+ const tabs = page.getByTestId('trip-section-overview').element().parentElement;
expect(tabs?.className).toContain('px-4');
});
-test.each(['overview', 'itinerary'] as const)('combines viewing tabs on the %s URL', (section) => {
- render();
- expect(screen.getByTestId('trip-section-overview').textContent).toBe('Trip');
- expect(screen.queryByTestId('trip-section-itinerary')).toBeNull();
-});
+test.each(['overview', 'itinerary'] as const)(
+ 'combines viewing tabs on the %s URL',
+ async (section) => {
+ await render(
+
+ );
+ await expect.element(page.getByTestId('trip-section-overview')).toHaveTextContent('Trip');
+ await expect.element(page.getByTestId('trip-section-itinerary')).not.toBeInTheDocument();
+ }
+);
diff --git a/apps/web/src/features/trips/trip-forms/trip-information-fields.tsx b/apps/web/src/features/trips/trip-forms/trip-information-fields.tsx
index 78529e36..6a3d6521 100644
--- a/apps/web/src/features/trips/trip-forms/trip-information-fields.tsx
+++ b/apps/web/src/features/trips/trip-forms/trip-information-fields.tsx
@@ -141,6 +141,7 @@ export function TripInformationFields({
setValue('budget', event.target.value)}
diff --git a/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.test.tsx b/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.test.tsx
index 832762ae..f2a91ac1 100644
--- a/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.test.tsx
+++ b/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.test.tsx
@@ -1,11 +1,10 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { render } from '@groam/vitest-config/react';
+import { expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
import { CreateTripIdeaDialog } from '@/features/trips/trip-ideas/create-trip-idea-dialog';
-afterEach(cleanup);
-
-test('explains that starting an idea creates private work from the shared trip', () => {
- render(
+test('explains that starting an idea creates private work from the shared trip', async () => {
+ await render(
);
- expect(screen.getByRole('heading', { name: 'New idea' })).toBeTruthy();
- expect(screen.getByText(/idea from the shared trip/u)).toBeTruthy();
- expect(screen.getByText(/reviewed and applied/u)).toBeTruthy();
- expect(screen.getByText('Atlantic week')).toBeTruthy();
- expect(screen.getByText('Everyone sees this')).toBeTruthy();
- expect(screen.getByText('Edit here')).toBeTruthy();
- expect(screen.getByRole('button', { name: 'Create idea' })).toBeTruthy();
+ await expect.element(page.getByRole('heading', { name: 'New idea' })).toBeInTheDocument();
+ await expect.element(page.getByText(/idea from the shared trip/u)).toBeInTheDocument();
+ await expect.element(page.getByText(/reviewed and applied/u)).toBeInTheDocument();
+ await expect.element(page.getByText('Atlantic week')).toBeInTheDocument();
+ await expect.element(page.getByText('Everyone sees this')).toBeInTheDocument();
+ await expect.element(page.getByText('Edit here')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Create idea' })).toBeInTheDocument();
});
test('submits an optional custom idea name', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
- render(
+ await render(
);
- fireEvent.change(screen.getByLabelText('What do you want to change?'), {
- target: { value: ' Coastal route ' }
- });
- fireEvent.click(screen.getByRole('button', { name: 'Create idea' }));
+ await page.getByLabelText('What do you want to change?').fill(' Coastal route ');
+ await page.getByRole('button', { name: 'Create idea' }).click();
- await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Coastal route'));
+ await vi.waitFor(() => expect(onCreate).toHaveBeenCalledWith('Coastal route'));
});
test('requests an automatically generated name when left blank', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
- render(
+ await render(
);
- fireEvent.click(screen.getByRole('button', { name: 'Create idea' }));
+ await page.getByRole('button', { name: 'Create idea' }).click();
- await waitFor(() => expect(onCreate).toHaveBeenCalledWith(undefined));
+ await vi.waitFor(() => expect(onCreate).toHaveBeenCalledWith(undefined));
});
-test('keeps the dialog open while an idea is being created', () => {
+test('keeps the dialog open while an idea is being created', async () => {
const onOpenChange = vi.fn();
- render();
+ await render(
+
+ );
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+ // Forced: a disabled control must ignore clicks, which is the behavior under test.
+ await page.getByRole('button', { name: 'Cancel' }).click({ force: true });
expect(onOpenChange).not.toHaveBeenCalled();
});
-test('does not mistake trips still loading for an empty group', () => {
- render(
+test('does not mistake trips still loading for an empty group', async () => {
+ await render(
{
tripsLoading
/>
);
- expect(screen.getByText('Loading trips…')).toBeTruthy();
- expect(screen.queryByText('Start with a trip')).toBeNull();
- expect(screen.getByRole('button', { name: 'Create idea' }).hasAttribute('disabled')).toBe(true);
+ await expect.element(page.getByText('Loading trips…')).toBeInTheDocument();
+ await expect.element(page.getByText('Start with a trip')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Create idea' })).toBeDisabled();
});
diff --git a/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.tsx b/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.tsx
index 958513cd..605b8f6d 100644
--- a/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.tsx
+++ b/apps/web/src/features/trips/trip-ideas/create-trip-idea-dialog.tsx
@@ -93,7 +93,7 @@ export function CreateTripIdeaDialog({