From 874a02f14f6f9eb3061e1aedcc9e3f58973e3cd9 Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 13:31:57 +0000
Subject: [PATCH 01/15] Run browser tests in parallel on per-worker accounts
Each Playwright worker now creates its own user and group through the
backend, signs in once, and reuses that session. Specs arrange trips,
ideas, issues, and members through @groam/app-actions/backend and drive
only the journey under test through the UI, so the suite runs with
fullyParallel and CI needs two shards instead of four.
The dev container also installs Chromium and its libnss3/libnspr4
dependencies so browser tests run without manual setup.
Co-Authored-By: Claude Opus 5.5
---
.claude/skills/add-env-variable/SKILL.md | 2 +-
.claude/skills/add-test-id/SKILL.md | 5 +
.devcontainer/Dockerfile | 5 +-
.devcontainer/post-create.sh | 4 +
.github/workflows/test-reusable.yml | 12 +--
apps/web/e2e/assistant/open-assistant.spec.ts | 18 ++--
apps/web/e2e/chat/chat-reaction.spec.ts | 15 ++-
apps/web/e2e/chat/chat-with-the-group.spec.ts | 15 ++-
apps/web/e2e/group/invite-a-member.spec.ts | 20 ++--
apps/web/e2e/group/switch-groups.spec.ts | 13 +--
.../activities/apply-idea-activity.spec.ts | 36 +++----
.../cancel-idea-activity-edit.spec.ts | 17 ++--
.../activities/edit-idea-activities.spec.ts | 39 ++++----
.../edit-multiday-idea-activity.spec.ts | 17 ++--
.../activities/inline-idea-activities.spec.ts | 39 ++++----
.../activities/undated-idea-activity.spec.ts | 13 ++-
.../e2e/ideas/planning/idea-layout.spec.ts | 23 ++---
.../planning/plan-a-trip-itinerary.spec.ts | 20 ++--
.../e2e/ideas/planning/start-an-idea.spec.ts | 10 +-
.../start-idea-from-workspace.spec.ts | 16 ++--
.../start-with-first-destination.spec.ts | 13 +--
apps/web/e2e/ideas/review/close-idea.spec.ts | 30 +++---
.../e2e/ideas/review/idea-apply-gate.spec.ts | 23 +++--
.../idea-review-requires-change.spec.ts | 14 ++-
.../ideas/review/review-idea-changes.spec.ts | 18 ++--
.../review/review-idea-permissions.spec.ts | 70 ++++++--------
.../review/reviewer-layout.visual.spec.ts | 6 +-
.../ideas/review/revoke-idea-approval.spec.ts | 55 +++++------
apps/web/e2e/issues/create-an-issue.spec.ts | 13 ++-
.../issues/issue-comment-persistence.spec.ts | 14 ++-
apps/web/e2e/issues/reopen-issue.spec.ts | 17 ++--
...esolve-an-issue-with-a-merged-idea.spec.ts | 40 ++++----
.../web/e2e/trips/archive-trip-filter.spec.ts | 13 +--
apps/web/e2e/trips/create-a-trip.spec.ts | 6 +-
.../resolve-trip-detail-conflicts.spec.ts | 24 +++--
apps/web/e2e/trips/trip-search.spec.ts | 19 ++--
.../e2e/workspace/browse-workspace.spec.ts | 27 +++---
.../workspace/workspace-idea-search.spec.ts | 21 ++--
.../workspace/workspace-issue-search.spec.ts | 21 ++--
apps/web/playwright.config.ts | 8 +-
packages/env/README.md | 2 +-
packages/env/src/apps/playwright.ts | 12 +--
tooling/app-actions/package.json | 3 +-
tooling/app-actions/src/backend/add-idea.ts | 24 ++++-
.../src/backend/backend-app-actions.ts | 4 +-
tooling/app-actions/src/backend/index.ts | 1 +
.../playwright/add-group-member-via-invite.ts | 25 -----
tooling/app-actions/src/playwright/index.ts | 6 +-
.../app-actions/src/playwright/open-app.ts | 9 ++
.../app-actions/src/playwright/open-idea.ts | 23 +++++
.../app-actions/src/playwright/open-issue.ts | 17 ++++
.../app-actions/src/playwright/open-trip.ts | 17 ++++
tooling/app-actions/src/playwright/sign-in.ts | 7 --
tooling/app-actions/src/playwright/test.ts | 95 +++++++++++++++++++
.../src/playwright/worker-account.ts | 41 ++++++++
55 files changed, 613 insertions(+), 464 deletions(-)
delete mode 100644 tooling/app-actions/src/playwright/add-group-member-via-invite.ts
create mode 100644 tooling/app-actions/src/playwright/open-app.ts
create mode 100644 tooling/app-actions/src/playwright/open-idea.ts
create mode 100644 tooling/app-actions/src/playwright/open-issue.ts
create mode 100644 tooling/app-actions/src/playwright/open-trip.ts
delete mode 100644 tooling/app-actions/src/playwright/sign-in.ts
create mode 100644 tooling/app-actions/src/playwright/test.ts
create mode 100644 tooling/app-actions/src/playwright/worker-account.ts
diff --git a/.claude/skills/add-env-variable/SKILL.md b/.claude/skills/add-env-variable/SKILL.md
index 265794ea..008b4e77 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 |
+| `playwright` | web E2E | `PLAYWRIGHT_BASE_URL`, `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..b751d535 100644
--- a/.claude/skills/add-test-id/SKILL.md
+++ b/.claude/skills/add-test-id/SKILL.md
@@ -38,6 +38,11 @@ Stable locators are the `ids` object in
`tooling/app-actions/src/playwright/.ts` (exported from its
`index.ts`) instead of repeating the steps in a spec under
`apps/web/e2e/`.
+5. Specs import `test`/`expect` from `@groam/app-actions/playwright/test`:
+ `page` is already signed in as the worker's own account and group, and
+ the `arrange` fixture creates trips, ideas, issues, and members through
+ the backend. Drive only the journey under test through the UI; second
+ users get a page from `newSignedOutPage()`.
## Rules
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..5dcbc316 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 tests need the Chromium build matching the locked Playwright version;
+# its system libraries come from the Dockerfile.
+vp run test:e2e: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/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml
index 1bc10819..fc288eef 100644
--- a/.github/workflows/test-reusable.yml
+++ b/.github/workflows/test-reusable.yml
@@ -51,14 +51,15 @@ jobs:
run: bun run check
e2e:
- name: Browser (${{ matrix.shard }}/4)
+ name: Browser (${{ matrix.shard }}/2)
needs: [quality]
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
- shard: [1, 2, 3, 4]
+ # Each shard runs two Playwright workers, each with its own account and group.
+ shard: [1, 2]
env:
CI: 'true'
CONVEX_AGENT_MODE: anonymous
@@ -102,7 +103,9 @@ jobs:
bunx convex env set BETTER_AUTH_SECRET "ci-only-better-auth-secret-not-for-production"
bunx convex env set SITE_URL "$PLAYWRIGHT_BASE_URL/"
+ # Generated code is identical in every shard; verifying it once is enough.
- name: Verify generated code
+ if: matrix.shard == 1
run: |
bun run codegen
git diff --exit-code
@@ -120,11 +123,8 @@ jobs:
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
+ run: bun run test:e2e -- --shard=${{ matrix.shard }}/2
- name: Upload failure artifacts
if: failure()
diff --git a/apps/web/e2e/assistant/open-assistant.spec.ts b/apps/web/e2e/assistant/open-assistant.spec.ts
index c1aa6214..658145b2 100644
--- a/apps/web/e2e/assistant/open-assistant.spec.ts
+++ b/apps/web/e2e/assistant/open-assistant.spec.ts
@@ -1,15 +1,9 @@
-import {
- completeOnboarding,
- createTrip,
- openGroamAssistant,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { test } from '@playwright/test';
+import { openGroamAssistant, openTrip, uniqueSuffix } from '@groam/app-actions/playwright';
+import { test } from '@groam/app-actions/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()}` });
+test('opens Groam AI on a trip with a ready prompt', async ({ arrange, page }) => {
+ // Each worker 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 openTrip(page, tripId);
await openGroamAssistant(page);
});
diff --git a/apps/web/e2e/chat/chat-reaction.spec.ts b/apps/web/e2e/chat/chat-reaction.spec.ts
index 25897daf..5b1a8a1d 100644
--- a/apps/web/e2e/chat/chat-reaction.spec.ts
+++ b/apps/web/e2e/chat/chat-reaction.spec.ts
@@ -1,24 +1,21 @@
import {
- addGroupMemberViaInvite,
chatMessage,
chatRow,
createGroupChat,
+ openApp,
openChatInbox,
reactToChatMessage,
reloadAppPage,
sendChatMessage,
- signIn,
- uniqueSuffix,
- uniqueTestUser
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('a chat reaction remains visible after a page reload', async ({ browser, page }) => {
+test('a chat reaction remains visible after a page reload', async ({ arrange, page }) => {
test.setTimeout(180_000);
- await signIn(page);
const suffix = uniqueSuffix();
- const member = uniqueTestUser('reaction-member');
- await addGroupMemberViaInvite(browser, page, member);
+ const member = await arrange.addMember('reaction-member');
+ await openApp(page);
await openChatInbox(page);
const title = `Reactions ${suffix}`;
await createGroupChat(page, { memberName: member.name, title });
diff --git a/apps/web/e2e/chat/chat-with-the-group.spec.ts b/apps/web/e2e/chat/chat-with-the-group.spec.ts
index c37a14f9..eece3789 100644
--- a/apps/web/e2e/chat/chat-with-the-group.spec.ts
+++ b/apps/web/e2e/chat/chat-with-the-group.spec.ts
@@ -1,21 +1,18 @@
import {
- addGroupMemberViaInvite,
chatRow,
createGroupChat,
+ openApp,
openChatInbox,
sendMessage,
- signIn,
- uniqueSuffix,
- uniqueTestUser
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('chats with a group member', async ({ browser, page }) => {
+test('chats with a group member', async ({ arrange, page }) => {
test.setTimeout(180_000);
- await signIn(page);
const suffix = uniqueSuffix();
- const member = uniqueTestUser('chat-member');
- await addGroupMemberViaInvite(browser, page, member);
+ const member = await arrange.addMember('chat-member');
+ await openApp(page);
await openChatInbox(page);
await createGroupChat(page, { memberName: member.name, title: `Dates thread ${suffix}` });
diff --git a/apps/web/e2e/group/invite-a-member.spec.ts b/apps/web/e2e/group/invite-a-member.spec.ts
index fcc017ba..3b86d6c8 100644
--- a/apps/web/e2e/group/invite-a-member.spec.ts
+++ b/apps/web/e2e/group/invite-a-member.spec.ts
@@ -2,35 +2,34 @@ import {
acceptGroupInvitation,
addIdea,
by,
- createTrip,
ids,
inviteGroupMember,
+ openApp,
+ openTrip,
openTripFromList,
openTrips,
redeemGroupInvitationCode,
- signIn,
tripCard,
uniqueSuffix,
uniqueTestUser,
updateTrip
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('copies and pastes an invitation code that joins a member to the group', async ({
- browser,
+ arrange,
+ newSignedOutPage,
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 });
+ await openTrip(page, await arrange.createTrip({ name: tripName }));
const invitationCode = await inviteGroupMember(page);
- const memberContext = await browser.newContext();
- const memberPage = await memberContext.newPage();
+ const memberPage = await newSignedOutPage();
await acceptGroupInvitation(memberPage, invitationCode, member);
await openTripFromList(memberPage, tripName);
await expect(by(memberPage, ids.tripRole)).toHaveAttribute('data-role', 'participant');
@@ -41,9 +40,6 @@ test('copies and pastes an invitation code that joins a member to the group', as
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();
});
@@ -52,7 +48,7 @@ test('opens the group gracefully when the code belongs to an existing membership
page
}) => {
test.setTimeout(90_000);
- await signIn(page);
+ await openApp(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);
diff --git a/apps/web/e2e/group/switch-groups.spec.ts b/apps/web/e2e/group/switch-groups.spec.ts
index 556de80b..915b98a2 100644
--- a/apps/web/e2e/group/switch-groups.spec.ts
+++ b/apps/web/e2e/group/switch-groups.spec.ts
@@ -4,17 +4,18 @@ import {
createTrip,
deleteCurrentGroup,
ids,
+ openApp,
openTrips,
- signIn,
switchGroup,
tripCard,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('group switching isolates reactive Convex data', async ({ page }) => {
+test('group switching isolates reactive Convex data', async ({ account, page }) => {
test.setTimeout(90_000);
- await signIn(page);
+ await openApp(page);
+ const homeGroupName = account.workspace.organizationName;
const organizationName = `Isolation ${uniqueSuffix()}`;
const tripName = `Only in ${organizationName}`;
@@ -23,12 +24,12 @@ test('group switching isolates reactive Convex data', async ({ page }) => {
await createTrip(page, { name: tripName });
await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
- await switchGroup(page, { name: 'Groam Demo' });
+ await switchGroup(page, { name: homeGroupName });
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' });
+ await deleteCurrentGroup(page, { 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
index 3a3fafbe..3cabdafa 100644
--- a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
+++ b/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
@@ -1,36 +1,34 @@
import {
- acceptGroupInvitation,
addActivity,
- addIdea,
addStop,
applyIdea,
approveIdea,
- createTrip,
- inviteGroupMember,
mockDestinationSearch,
openAppPath,
+ openIdea,
openIdeaFromList,
openItinerary,
openSharedTrip,
requestIdeaReview,
saveSevenDayRange,
- signIn,
- uniqueSuffix,
- uniqueTestUser
+ signInAs,
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('an approved idea activity appears on the shared itinerary only after applying it', async ({
- browser,
+ arrange,
+ newSignedOutPage,
page
}) => {
test.setTimeout(180_000);
await mockDestinationSearch(page);
- await signIn(page);
const suffix = uniqueSuffix();
- const tripId = await createTrip(page, { name: `Applied itinerary ${suffix}` });
+ const reviewerAccount = await arrange.addMember('activity-reviewer');
+ const sharedTripId = await arrange.createTrip({ name: `Applied itinerary ${suffix}` });
const ideaName = `Add a waterfront walk ${suffix}`;
- await addIdea(page, { name: ideaName });
+ const idea = await arrange.startIdea({ name: ideaName, tripId: sharedTripId });
+ const { tripId } = await openIdea(page, { proposalId: idea.proposalId, tripId: sharedTripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Porto', notes: 'River', startDay: '4', endDay: '5' });
@@ -44,16 +42,10 @@ test('an approved idea activity appears on the shared itinerary only after apply
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();
- }
+ const reviewer = await newSignedOutPage();
+ await signInAs(reviewer, reviewerAccount);
+ await openIdeaFromList(reviewer, ideaName);
+ await approveIdea(reviewer);
await openAppPath(page, `trips/${tripId}/itinerary`);
await expect(page.getByRole('region', { name: 'Day planner' })).toBeVisible();
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
index 7f80bed8..7eb7e235 100644
--- a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
+++ b/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
@@ -1,31 +1,32 @@
import {
addActivity,
- addIdea,
addStop,
- createTrip,
discardActivityChanges,
ids,
inlineActivityEditor,
keepActivityPlan,
mockDestinationSearch,
openActivityPlan,
+ openIdea,
openIdeaFromList,
openIdeaItinerary,
openItinerary,
saveSevenDayRange,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('cancelling edits or removal leaves the saved idea activity intact', async ({ page }) => {
+test('cancelling edits or removal leaves the saved idea activity intact', async ({
+ arrange,
+ page
+}) => {
test.setTimeout(120_000);
await mockDestinationSearch(page);
- await signIn(page);
const suffix = uniqueSuffix();
- await createTrip(page, { name: `Cancel edits ${suffix}` });
+ const tripId = await arrange.createTrip({ name: `Cancel edits ${suffix}` });
const ideaName = `Keep the plan ${suffix}`;
- await addIdea(page, { name: ideaName });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Porto', notes: 'Riverside', startDay: '1', endDay: '3' });
diff --git a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
index 90095f5f..4fca297e 100644
--- a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
+++ b/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
@@ -1,18 +1,17 @@
import {
addActivity,
- addIdea,
addStop,
continueActivityPlan,
- createIssue,
- createTrip,
editActivity,
ids,
inlineActivityEditor,
mockDestinationSearch,
openActivityPlan,
openIdeaComparison,
+ openIdea,
openIdeaFromList,
openIdeaItinerary,
+ openIssue,
openItinerary,
openSharedTrip,
openTripSection,
@@ -20,23 +19,23 @@ import {
requestIdeaReview,
returnIdeaToEditing,
saveSevenDayRange,
- signIn,
startActivityPlan,
startIdeaFromIssue,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('adds, edits and removes activities on a later-day idea from both entry points', async ({
+ arrange,
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 });
+ const tripId = await arrange.createTrip({ name: `Later-day trip ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '5' });
@@ -64,15 +63,16 @@ test('adds, edits and removes activities on a later-day idea from both entry poi
});
test('switches destination and day, returns from comparison, and submits activity changes for review', async ({
+ arrange,
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 });
+ const tripId = await arrange.createTrip({ name: `Review itinerary ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '3' });
@@ -106,18 +106,21 @@ test('switches destination and day, returns from comparison, and submits activit
});
test('adds an activity to an issue-linked idea without changing the shared trip', async ({
+ arrange,
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 tripId = await arrange.createTrip({ name: `Issue itinerary ${suffix}` });
+ await openIssue(
+ page,
+ await arrange.createIssue({
+ 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);
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
index 07c93fd1..2559ced2 100644
--- a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
+++ b/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
@@ -1,27 +1,28 @@
import {
- addIdea,
addStop,
continueActivityPlan,
- createTrip,
ids,
inlineActivityEditor,
mockDestinationSearch,
openActivityPlan,
+ openIdea,
openItinerary,
saveSevenDayRange,
- signIn,
startActivityPlan,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('editing from a continuation day shows the complete activity range', async ({ page }) => {
+test('editing from a continuation day shows the complete activity range', async ({
+ arrange,
+ 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}` });
+ const tripId = await arrange.createTrip({ name: `Multi-day plan ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: `Multi-day idea ${suffix}`, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '6' });
diff --git a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
index 0b1cfad2..33155ea1 100644
--- a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
+++ b/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
@@ -1,25 +1,26 @@
import {
- addIdea,
addInlineActivity,
addStop,
- createTrip,
editInlineActivity,
ids,
mockDestinationSearch,
+ openIdea,
openItinerary,
saveSevenDayRange,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('plans morning and afternoon activities directly within an idea day', async ({ page }) => {
+test('plans morning and afternoon activities directly within an idea day', async ({
+ arrange,
+ 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}` });
+ const tripId = await arrange.createTrip({ name: `Inline itinerary ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: `Plan this day ${suffix}`, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Lisbon', notes: 'Explore the city', startDay: '1', endDay: '3' });
@@ -49,13 +50,16 @@ test('plans morning and afternoon activities directly within an idea day', async
await expect(page.getByTestId(ids.activityInlineEditor)).toHaveCount(0);
});
-test('switching a shared day’s destination keeps the inline activity draft', async ({ page }) => {
+test('switching a shared day’s destination keeps the inline activity draft', async ({
+ arrange,
+ 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}` });
+ const tripId = await arrange.createTrip({ name: `Shared day ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: `Two places ${suffix}`, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '2' });
@@ -79,13 +83,16 @@ test('switching a shared day’s destination keeps the inline activity draft', a
).toContainText('Porto');
});
-test('mobile day planning keeps the inline activity form in the schedule', async ({ page }) => {
+test('mobile day planning keeps the inline activity form in the schedule', async ({
+ arrange,
+ 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}` });
+ const tripId = await arrange.createTrip({ name: `Mobile day plan ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: `Mobile activities ${suffix}`, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openItinerary(page);
await addStop(page, { name: 'Porto', notes: 'River', startDay: '2', endDay: '3' });
diff --git a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
index 66946af1..ce0a51fd 100644
--- a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
+++ b/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
@@ -1,28 +1,27 @@
import {
addActivity,
addFirstDestination,
- addIdea,
- createTrip,
editActivity,
mockDestinationSearch,
+ openIdea,
openIdeaFromList,
openIdeaItinerary,
openItinerary,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('plans and revisits an activity before trip dates or destination days are set', async ({
+ arrange,
page
}) => {
test.setTimeout(120_000);
await mockDestinationSearch(page);
- await signIn(page);
const suffix = uniqueSuffix();
- await createTrip(page, { name: `Flexible trip ${suffix}` });
+ const tripId = await arrange.createTrip({ name: `Flexible trip ${suffix}` });
const ideaName = `Plan before dates ${suffix}`;
- await addIdea(page, { name: ideaName });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openIdea(page, { proposalId, tripId });
await openItinerary(page);
await addFirstDestination(page, { name: 'Porto', notes: 'River walk' });
await addActivity(page, { destination: 'Porto, Portugal', title: 'Explore the old town' });
diff --git a/apps/web/e2e/ideas/planning/idea-layout.spec.ts b/apps/web/e2e/ideas/planning/idea-layout.spec.ts
index 21b53619..a61dd537 100644
--- a/apps/web/e2e/ideas/planning/idea-layout.spec.ts
+++ b/apps/web/e2e/ideas/planning/idea-layout.spec.ts
@@ -1,26 +1,28 @@
import {
- addIdea,
by,
- createTrip,
ids,
+ openApp,
openIdeaComparison,
openIdeaFromList,
+ openTrip,
openTripSection,
openWorkspaceIdeas,
pageCanvas,
resolvedBackground,
- signIn,
twoColumnPageLayout,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('idea tabs share the page canvas while itinerary uses its full width', async ({ page }) => {
+test('idea tabs share the page canvas while itinerary uses its full width', async ({
+ arrange,
+ page
+}) => {
test.setTimeout(120_000);
- await signIn(page);
- await createTrip(page, { name: `Layout defaults ${uniqueSuffix()}` });
+ const tripId = await arrange.createTrip({ name: `Layout defaults ${uniqueSuffix()}` });
const ideaName = `Consistent tabs ${uniqueSuffix()}`;
- await addIdea(page, { name: ideaName });
+ await arrange.startIdea({ name: ideaName, tripId });
+ await openApp(page);
await openWorkspaceIdeas(page);
const sidebarLink = await by(page, ids.navTrips).elementHandle();
if (!sidebarLink) throw new Error('Expected workspace navigation');
@@ -66,10 +68,9 @@ test('idea tabs share the page canvas while itinerary uses its full width', asyn
}
});
-test('all shared trip tabs retain the Overview canvas', async ({ page }) => {
+test('all shared trip tabs retain the Overview canvas', async ({ arrange, page }) => {
test.setTimeout(120_000);
- await signIn(page);
- await createTrip(page, { name: `Trip canvas ${uniqueSuffix()}` });
+ await openTrip(page, await arrange.createTrip({ name: `Trip canvas ${uniqueSuffix()}` }));
for (const dark of [false, true]) {
await page.evaluate(
(enabled) => document.documentElement.classList.toggle('dark', enabled),
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
index b4f0249e..cf2bcd14 100644
--- a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
+++ b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
@@ -1,33 +1,29 @@
import {
addActivity,
- addIdea,
addStop,
- createTrip,
editActivity,
mockDestinationSearch,
+ openIdea,
openItinerary,
openTrips,
saveSevenDayRange,
- signIn,
- uniqueSuffix,
- updateTrip
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('plans a trip itinerary on an idea working copy', async ({ page }) => {
+test('plans a trip itinerary on an idea working copy', async ({ arrange, 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 });
+ const tripId = await arrange.createTrip({ name: tripName });
+ const idea = await arrange.startIdea({ name: ideaName, tripId });
+ await arrange.updateTrip({ budgetAmount: 1800, tripId: idea.workingTripId });
+ await openIdea(page, { proposalId: idea.proposalId, tripId });
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' });
diff --git a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts b/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
index 99b2b986..c564eebe 100644
--- a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
+++ b/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
@@ -1,26 +1,24 @@
import {
addIdea,
by,
- createTrip,
ideaRow,
ids,
openIdeaFromList,
openSharedTrip,
+ openTrip,
openTripSection,
openWorkspaceIdeas,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('starts an idea and opens first-class idea routes', async ({ page }) => {
+test('starts an idea and opens first-class idea routes', async ({ arrange, 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 });
+ const tripId = await openTrip(page, await arrange.createTrip({ name: tripName }));
await addIdea(page, { name: ideaName });
await openSharedTrip(page);
await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
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
index ea405e03..2bff8875 100644
--- a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
+++ b/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
@@ -1,26 +1,26 @@
import {
by,
- createTrip,
ids,
+ openApp,
openSharedTrip,
- signIn,
startWorkspaceIdea,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('starts a new idea from workspace Ideas for the selected trip', async ({ page }) => {
+test('starts a new idea from workspace Ideas for the selected trip', async ({ arrange, 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 });
+ await arrange.createTrip({ name: firstName });
+ await arrange.createTrip({ name: selectedName });
+ await openApp(page);
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'));
+ // The shared trip check below proves the idea belongs to the selected trip.
+ await expect(page).toHaveURL(/\/trips\/[^/]+\/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);
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
index 1222ba20..6c3e16bd 100644
--- a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
+++ b/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
@@ -2,21 +2,22 @@ import {
addFirstDestination,
addIdea,
by,
- createTrip,
ids,
mockDestinationSearch,
openDestinationDetails,
+ openTrip,
openTripSection,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('starts an idea and adds the first destination through one composer', async ({ page }) => {
+test('starts an idea and adds the first destination through one composer', async ({
+ arrange,
+ page
+}) => {
test.setTimeout(120_000);
await mockDestinationSearch(page);
- await signIn(page);
- await createTrip(page, { name: `First destination ${uniqueSuffix()}` });
+ await openTrip(page, await arrange.createTrip({ name: `First destination ${uniqueSuffix()}` }));
await expect(by(page, ids.tripActionAddStop)).toHaveCount(0);
await addIdea(page, { name: `First stop idea ${uniqueSuffix()}` });
await openTripSection(page, 'itinerary');
diff --git a/apps/web/e2e/ideas/review/close-idea.spec.ts b/apps/web/e2e/ideas/review/close-idea.spec.ts
index 4358d2a3..e9cec088 100644
--- a/apps/web/e2e/ideas/review/close-idea.spec.ts
+++ b/apps/web/e2e/ideas/review/close-idea.spec.ts
@@ -1,24 +1,20 @@
import {
- addIdea,
closeIdea,
- createTrip,
filterWorkspaceIdeas,
ideaRow,
ids,
- requestIdeaReview,
- signIn,
- uniqueSuffix,
- updateTrip
+ openIdea,
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('deletes a draft idea without leaving an editable working copy', async ({ page }) => {
+test('deletes a draft idea without leaving an editable working copy', async ({ arrange, page }) => {
test.setTimeout(120_000);
- await signIn(page);
const suffix = uniqueSuffix();
- await createTrip(page, { name: `Abandoned trip ${suffix}` });
+ const tripId = await arrange.createTrip({ name: `Abandoned trip ${suffix}` });
const name = `Abandoned idea ${suffix}`;
- await addIdea(page, { name });
+ const { proposalId } = await arrange.startIdea({ name, tripId });
+ await openIdea(page, { proposalId, tripId });
await closeIdea(page);
await filterWorkspaceIdeas(page, 'Settled', [name]);
const row = ideaRow(page, name);
@@ -26,15 +22,15 @@ test('deletes a draft idea without leaving an editable working copy', async ({ p
await expect(row.getByTestId(ids.ideaContinue)).toHaveCount(0);
});
-test('requires a reason to pass on an idea already in review', async ({ page }) => {
+test('requires a reason to pass on an idea already in review', async ({ arrange, page }) => {
test.setTimeout(120_000);
- await signIn(page);
const suffix = uniqueSuffix();
- await createTrip(page, { name: `Review trip ${suffix}` });
+ const tripId = await arrange.createTrip({ 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);
+ const idea = await arrange.startIdea({ name, tripId });
+ await arrange.updateTrip({ dateNotes: 'Try a different week', tripId: idea.workingTripId });
+ await arrange.requestIdeaReview(idea.proposalId);
+ await openIdea(page, { proposalId: idea.proposalId, tripId });
await closeIdea(page, 'The original week works better');
await filterWorkspaceIdeas(page, 'Settled', [name]);
const row = ideaRow(page, name);
diff --git a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts b/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
index 861338c9..919652e1 100644
--- a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
+++ b/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
@@ -1,26 +1,25 @@
import {
- addIdea,
by,
- createTrip,
ids,
+ openIdea,
+ openIdeaComparison,
reloadAppPage,
- requestIdeaReview,
- signIn,
- uniqueSuffix,
- updateTrip
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('submitting an idea for review does not allow applying it without approval', async ({
+ arrange,
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);
+ 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 openIdea(page, { proposalId: idea.proposalId, tripId });
+ await openIdeaComparison(page);
await expect(by(page, ids.applyIdea).first()).toBeVisible();
await expect(by(page, 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
index 93ba6f4b..708809f3 100644
--- a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
+++ b/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
@@ -1,21 +1,19 @@
import {
- addIdea,
by,
- createTrip,
ids,
+ openIdea,
openIdeaComparison,
- signIn,
uniqueSuffix,
updateTrip
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('an empty idea cannot be submitted until it contains a change', async ({ page }) => {
+test('an empty idea cannot be submitted until it contains a change', async ({ arrange, 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}` });
+ const tripId = await arrange.createTrip({ name: `Empty proposal ${suffix}` });
+ const { proposalId } = await arrange.startIdea({ name: `Fill this idea ${suffix}`, tripId });
+ await openIdea(page, { proposalId, tripId });
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();
diff --git a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts b/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
index d58b44df..dd5a8bc9 100644
--- a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
+++ b/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
@@ -1,26 +1,24 @@
import {
- addIdea,
by,
- createTrip,
ids,
+ openIdea,
returnIdeaToEditing,
reviewIdeaChange,
- signIn,
- uniqueSuffix,
- updateTrip
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('reviews an edit, comments without blocking it, and returns to editing on mobile', async ({
+ arrange,
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 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 updateTrip(page, { dateNotes: note });
+ await arrange.updateTrip({ dateNotes: note, tripId: idea.workingTripId });
+ await openIdea(page, { proposalId: idea.proposalId, tripId });
const comment = `Looks good, keeping this flexible. ${suffix}`;
await reviewIdeaChange(page, { change: note, comment });
await expect(by(page, ids.requestReview)).toBeEnabled();
diff --git a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts b/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
index 5fe8e7e6..e5c243de 100644
--- a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
+++ b/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
@@ -1,38 +1,36 @@
import {
- acceptGroupInvitation,
addActivity,
- addIdea,
addStop,
approveIdea,
- createTrip,
ids,
- inviteGroupMember,
mockDestinationSearch,
openAppPath,
+ openIdea,
openIdeaComparison,
openIdeaFromList,
openIdeaItinerary,
openTripSection,
requestIdeaReview,
saveSevenDayRange,
- signIn,
+ signInAs,
uniqueSuffix,
- uniqueTestUser,
updateTrip
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('reviewers can inspect and approve an idea but cannot edit its working copy', async ({
- browser,
+ arrange,
+ newSignedOutPage,
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 reviewerAccount = await arrange.addMember('reviewer');
+ const tripId = await arrange.createTrip({ name: `Read-only review ${suffix}` });
const ideaName = `Review-only idea ${suffix}`;
- const proposalId = await addIdea(page, { name: ideaName });
+ const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
+ await openIdea(page, { proposalId, tripId });
await saveSevenDayRange(page);
await openTripSection(page, 'overview');
await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '1', endDay: '3' });
@@ -40,33 +38,27 @@ test('reviewers can inspect and approve an idea but cannot edit its working copy
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);
+ const reviewer = await newSignedOutPage();
+ await signInAs(reviewer, reviewerAccount);
+ 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();
- }
+ await openIdeaFromList(page, ideaName);
+ await openIdeaItinerary(page);
+ await expect(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
index e7040ee1..27c49259 100644
--- a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
+++ b/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
@@ -1,8 +1,8 @@
-import { ids, openBackgroundActivity, signIn } from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { ids, openApp, openBackgroundActivity } from '@groam/app-actions/playwright';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('background activity is discoverable in settings and opens a real run', async ({ page }) => {
- await signIn(page);
+ await openApp(page);
await expect(page.getByRole('complementary').getByTestId(ids.navAgents)).toHaveCount(0);
const result = await openBackgroundActivity(page);
if (result === 'empty') {
diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
index 998fdfd2..c8bc1267 100644
--- a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
+++ b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
@@ -1,51 +1,40 @@
import {
- acceptGroupInvitation,
- addIdea,
approveIdea,
by,
- createTrip,
ids,
- inviteGroupMember,
+ openIdea,
openIdeaFromList,
- requestIdeaReview,
revokeIdeaApproval,
- signIn,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
+ signInAs,
+ uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('revoking the only approval prevents an idea from being applied', async ({
- browser,
+ arrange,
+ newSignedOutPage,
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 reviewerAccount = await arrange.addMember('approval-reversal');
+ 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 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();
+ const reviewer = await newSignedOutPage();
+ await signInAs(reviewer, reviewerAccount);
+ await openIdeaFromList(reviewer, ideaName);
+ await approveIdea(reviewer);
+ await openIdea(page, { proposalId: idea.proposalId, tripId });
+ 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 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();
- }
+ await approveIdea(reviewer);
+ await expect(by(page, 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
index 12a51c3e..ee81e9ee 100644
--- a/apps/web/e2e/issues/create-an-issue.spec.ts
+++ b/apps/web/e2e/issues/create-an-issue.spec.ts
@@ -2,29 +2,28 @@ import {
by,
createIssue,
createPlaywrightActions,
- createTrip,
filterIssuesByStatus,
ids,
issueRow,
+ openApp,
openAppPath,
openIssueFromInbox,
openIssuesInbox,
+ openTrip,
openTripSection,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('creates a trip issue and opens it as a first-class page', async ({ page }) => {
+test('creates a trip issue and opens it as a first-class page', async ({ arrange, 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 tripId = await openTrip(page, await arrange.createTrip({ 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');
@@ -55,7 +54,7 @@ test('creates a trip issue and opens it as a first-class page', async ({ page })
});
test('lists workspace issues from the sidebar', async ({ page }) => {
- await signIn(page);
+ await openApp(page);
await openIssuesInbox(page);
await expect(by(page, ids.issuesTitle)).toBeVisible();
});
diff --git a/apps/web/e2e/issues/issue-comment-persistence.spec.ts b/apps/web/e2e/issues/issue-comment-persistence.spec.ts
index a788d55c..91d50dcc 100644
--- a/apps/web/e2e/issues/issue-comment-persistence.spec.ts
+++ b/apps/web/e2e/issues/issue-comment-persistence.spec.ts
@@ -1,24 +1,22 @@
import {
by,
- createIssue,
createPlaywrightActions,
- createTrip,
ids,
+ openIssue,
openIssueFromInbox,
openIssueFromTrip,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('issue comments persist across trip and workspace views', async ({ page }) => {
+test('issue comments persist across trip and workspace views', async ({ arrange, 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 });
+ const tripId = await arrange.createTrip({ name: `Comment trip ${suffix}` });
+ const issueId = await arrange.createIssue({ body: 'Need an indoor alternative.', title, tripId });
+ await openIssue(page, issueId);
await createPlaywrightActions().addIssueComment(page, { content: comment, issueId });
await expect(by(page, ids.issueComment)).toBeEmpty();
diff --git a/apps/web/e2e/issues/reopen-issue.spec.ts b/apps/web/e2e/issues/reopen-issue.spec.ts
index c8036a03..7177dc7b 100644
--- a/apps/web/e2e/issues/reopen-issue.spec.ts
+++ b/apps/web/e2e/issues/reopen-issue.spec.ts
@@ -1,25 +1,26 @@
import {
by,
- createIssue,
- createTrip,
filterIssuesByStatus,
ids,
issueRow,
+ openIssue,
openIssueFromInbox,
openIssuesInbox,
setIssueStatus,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('reopening a closed issue moves it back into the open workspace filter', async ({ page }) => {
+test('reopening a closed issue moves it back into the open workspace filter', async ({
+ arrange,
+ 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 });
+ const tripId = await arrange.createTrip({ name: `Issue reopen trip ${suffix}` });
+ const issueId = await arrange.createIssue({ body: 'Check indoor alternatives.', title, tripId });
+ await openIssue(page, issueId);
await setIssueStatus(page, { issueId, status: 'closed' });
await openIssuesInbox(page);
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
index 5332432b..28a57faa 100644
--- 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
@@ -1,54 +1,48 @@
import {
- acceptGroupInvitation,
applyIdea,
approveIdea,
by,
- createIssue,
- createTrip,
idFromPath,
ids,
- inviteGroupMember,
openAppPath,
openIdeaFromList,
+ openIssue,
requestIdeaReview,
- signIn,
+ signInAs,
startIdeaFromIssue,
uniqueSuffix,
- uniqueTestUser,
updateTrip
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('creates a trip, makes an issue, and resolves it with a merged idea', async ({
- browser,
+ arrange,
+ newSignedOutPage,
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 member = await arrange.addMember('approver');
+ const tripId = await arrange.createTrip({ name: `Merge trip ${suffix}` });
+ const issueId = await openIssue(
+ page,
+ await arrange.createIssue({
+ 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);
+ const memberPage = await newSignedOutPage();
+ await signInAs(memberPage, 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);
diff --git a/apps/web/e2e/trips/archive-trip-filter.spec.ts b/apps/web/e2e/trips/archive-trip-filter.spec.ts
index 05030b60..64349926 100644
--- a/apps/web/e2e/trips/archive-trip-filter.spec.ts
+++ b/apps/web/e2e/trips/archive-trip-filter.spec.ts
@@ -1,22 +1,23 @@
import {
archiveTrip,
- createTrip,
filterTripsByStatus,
openAppPath,
+ openTrip,
openTripFromList,
openTrips,
restoreTrip,
- signIn,
tripCard,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('archived trips leave the active list and return after restoring', async ({ page }) => {
+test('archived trips leave the active list and return after restoring', async ({
+ arrange,
+ page
+}) => {
test.setTimeout(90_000);
- await signIn(page);
const tripName = `Archive filter ${uniqueSuffix()}`;
- await createTrip(page, { name: tripName });
+ await openTrip(page, await arrange.createTrip({ name: tripName }));
await archiveTrip(page);
await openAppPath(page, 'trips');
diff --git a/apps/web/e2e/trips/create-a-trip.spec.ts b/apps/web/e2e/trips/create-a-trip.spec.ts
index e88298ec..28c59746 100644
--- a/apps/web/e2e/trips/create-a-trip.spec.ts
+++ b/apps/web/e2e/trips/create-a-trip.spec.ts
@@ -3,17 +3,17 @@ import {
by,
createTrip,
ids,
+ openApp,
openTrips,
restoreTrip,
- signIn,
tripCard,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('creates, archives, restores, and lists a destination-optional trip', async ({ page }) => {
test.setTimeout(90_000);
- await signIn(page);
+ await openApp(page);
const tripName = `Beach idea ${uniqueSuffix()}`;
await createTrip(page, {
diff --git a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts b/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
index ae2ef815..b1050c42 100644
--- a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
+++ b/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
@@ -2,25 +2,25 @@ import {
addIdea,
approveAndApplyIdea,
by,
- createTrip,
ids,
openAppPath,
+ openIdea,
openIdeaComparison,
+ openTrip,
openTripSection,
reloadAppPage,
requestIdeaReview,
resolveTripDetailConflict,
- signIn,
uniqueSuffix,
updateTrip
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async ({
+ arrange,
page
}) => {
test.setTimeout(180_000);
- await signIn(page);
const suffix = uniqueSuffix('detail-conflict');
const initialName = `Original plan ${suffix}`;
const sharedName = `Updated shared plan ${suffix}`;
@@ -28,7 +28,8 @@ test('keeps ordinary edits quiet and resolves real competing ideas with mixed ch
const ideaTripName = `Idea plan ${suffix}`;
const ideaNotes = `Idea dates ${suffix}`;
- const tripId = await createTrip(page, { name: initialName });
+ const tripId = await arrange.createTrip({ name: initialName });
+ await openTrip(page, tripId);
await addIdea(page, { name: ideaName });
await updateTrip(page, { dateNotes: ideaNotes, name: ideaTripName });
await openTripSection(page, 'itinerary');
@@ -37,10 +38,15 @@ test('keeps ordinary edits quiet and resolves real competing ideas with mixed ch
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);
+ 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(page, { proposalId: competing.proposalId, tripId });
+ await openIdeaComparison(page);
await approveAndApplyIdea(page);
await openAppPath(page, firstIdeaUrl);
diff --git a/apps/web/e2e/trips/trip-search.spec.ts b/apps/web/e2e/trips/trip-search.spec.ts
index 9a35126a..15439ad7 100644
--- a/apps/web/e2e/trips/trip-search.spec.ts
+++ b/apps/web/e2e/trips/trip-search.spec.ts
@@ -1,24 +1,17 @@
-import {
- createTrip,
- openTrips,
- searchTrips,
- signIn,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { openApp, searchTrips, tripCard, uniqueSuffix } from '@groam/app-actions/playwright';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('trip search only shows matching plans and clearing it restores the list', async ({
+ arrange,
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 arrange.createTrip({ name: first });
+ await arrange.createTrip({ name: second });
+ await openApp(page);
await searchTrips(page, first);
await expect(tripCard(page, first)).toBeVisible();
diff --git a/apps/web/e2e/workspace/browse-workspace.spec.ts b/apps/web/e2e/workspace/browse-workspace.spec.ts
index 15b8584a..f4c49799 100644
--- a/apps/web/e2e/workspace/browse-workspace.spec.ts
+++ b/apps/web/e2e/workspace/browse-workspace.spec.ts
@@ -1,26 +1,22 @@
import {
- addIdea,
by,
- createIssue,
- createTrip,
expectHomeDashboard,
ids,
+ openApp,
openAppPath,
openChatInbox,
openFirstMobileListRow,
openIssuesInbox,
openMobileChat,
openSettingsGroupSection,
- openSharedTrip,
recoverFromNotFound,
selectSystemTheme,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
test('group people live in settings, not the sidebar', async ({ page }) => {
- await signIn(page);
+ await openApp(page);
await expect(page.getByRole('link', { name: 'Group', exact: true })).toHaveCount(0);
await openSettingsGroupSection(page);
await expect(by(page, ids.inviteMember)).toBeVisible();
@@ -41,7 +37,7 @@ test('workspace routes and 404 recovery work', async ({ page }) => {
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
- await signIn(page);
+ await openApp(page);
await expect(by(page, ids.tripsTitle)).toBeVisible();
await openIssuesInbox(page);
@@ -57,7 +53,7 @@ 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 openApp(page);
await expect(page.locator('html')).toHaveClass(/dark/u);
const navigation = by(page, ids.mobileNav);
@@ -66,14 +62,15 @@ test('system theme and mobile navigation work', async ({ page }) => {
await selectSystemTheme(page);
});
-test('mobile idea and issue lists keep titles readable and rows navigable', async ({ page }) => {
+test('mobile idea and issue lists keep titles readable and rows navigable', async ({
+ arrange,
+ 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, {
+ const tripId = await arrange.createTrip({ name: `Mobile lists ${suffix}` });
+ await arrange.startIdea({ name: `Mobile idea ${suffix}`, tripId });
+ await arrange.createIssue({
body: 'Keep this issue title readable on a narrow screen.',
title: `Mobile issue ${suffix}`,
tripId
diff --git a/apps/web/e2e/workspace/workspace-idea-search.spec.ts b/apps/web/e2e/workspace/workspace-idea-search.spec.ts
index 99bd8a2a..7d34ebf5 100644
--- a/apps/web/e2e/workspace/workspace-idea-search.spec.ts
+++ b/apps/web/e2e/workspace/workspace-idea-search.spec.ts
@@ -1,24 +1,25 @@
import {
- addIdea,
- createTrip,
ideaRow,
+ openApp,
openWorkspaceIdeas,
searchIdeas,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('workspace idea search finds the matching proposal and clears cleanly', async ({ page }) => {
+test('workspace idea search finds the matching proposal and clears cleanly', async ({
+ arrange,
+ 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 });
+ 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(page);
await openWorkspaceIdeas(page);
await searchIdeas(page, 'Museum proposal');
diff --git a/apps/web/e2e/workspace/workspace-issue-search.spec.ts b/apps/web/e2e/workspace/workspace-issue-search.spec.ts
index f6eeac71..43000f4b 100644
--- a/apps/web/e2e/workspace/workspace-issue-search.spec.ts
+++ b/apps/web/e2e/workspace/workspace-issue-search.spec.ts
@@ -1,25 +1,24 @@
import {
- createIssue,
- createTrip,
issueRow,
- openAppPath,
+ openApp,
openIssuesInbox,
searchIssues,
- signIn,
uniqueSuffix
} from '@groam/app-actions/playwright';
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@groam/app-actions/playwright/test';
-test('workspace issue search finds the matching issue and clears cleanly', async ({ page }) => {
+test('workspace issue search finds the matching issue and clears cleanly', async ({
+ arrange,
+ 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 });
+ 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(page);
await openIssuesInbox(page);
await searchIssues(page, 'Ferry issue');
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index ecb63617..6b49daea 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -3,9 +3,11 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
expect: { timeout: env.isCI ? 45_000 : 10_000 },
- fullyParallel: false,
+ // Every worker signs in as its own account and group (see @groam/app-actions/playwright/test),
+ // so tests share no data and can run in any order.
+ fullyParallel: true,
outputDir: 'test-results',
- reporter: env.isCI ? [['github'], ['html', { open: 'never' }]] : 'list',
+ reporter: env.isCI ? [['github'], ['list'], ['html', { open: 'never' }]] : 'list',
retries: env.isCI ? 2 : 0,
testDir: './e2e',
timeout: env.isCI ? 180_000 : 30_000,
@@ -15,5 +17,5 @@ export default defineConfig({
screenshot: 'only-on-failure',
trace: env.isCI ? 'on-first-retry' : 'retain-on-failure'
},
- workers: 1
+ workers: env.isCI ? 2 : 4
});
diff --git a/packages/env/README.md b/packages/env/README.md
index a3a0449d..5c90b256 100644
--- a/packages/env/README.md
+++ b/packages/env/README.md
@@ -17,7 +17,7 @@ import { env } from '@groam/env/seeder';
| `web-client` | `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL`, `VITE_SITE_URL`, Vite `BASE_URL`, `VITE_GROAM_SHELL` (`web` or `desktop`; default `web`), optional `VITE_PHOTON_URL` (default Photon), optional `VITE_MAP_TILES_URL` (OpenStreetMap raster template) |
| `runtime` | `CONVEX_AGENT_MODE`, `GROAM_CONVEX_URL`, `GROAM_DATA_DIR`, `GROAM_REPLACE_CONVEX_DIR`, `GROAM_SITE_ORIGIN`, `GROAM_STARTUP_TIMEOUT_MS` |
| `desktop-tooling` | `CI`, `CONVEX_AGENT_MODE`, `VITE_MAP_TILES_URL`, `VITE_PHOTON_URL` for desktop build and development scripts |
-| `playwright` | `PLAYWRIGHT_BASE_URL` (defaults to `VITE_CONVEX_SITE_URL` / Convex site `3211` for CI), `SEED_USER_EMAIL`, `SEED_USER_PASSWORD`. Local dev against Vite: `vp run test:e2e:local` (`http://localhost:5173`, must match `VITE_SITE_URL`). |
+| `playwright` | `PLAYWRIGHT_BASE_URL` (defaults to `VITE_CONVEX_SITE_URL` / Convex site `3211` for CI), `VITE_CONVEX_URL` and `VITE_CONVEX_SITE_URL` (local backend the per-worker test accounts are created on). Local dev against Vite: `vp run test:e2e:local` (`http://localhost:5173`, must match `VITE_SITE_URL`). |
| `seeder` | `SEED_CONVEX_URL`, `SEED_SITE_URL`, `SEED_USER_EMAIL`, `SEED_USER_PASSWORD`, `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL` |
`process.env` and `import.meta.env` access is restricted by the root lint
diff --git a/packages/env/src/apps/playwright.ts b/packages/env/src/apps/playwright.ts
index 449732b6..c74c6058 100644
--- a/packages/env/src/apps/playwright.ts
+++ b/packages/env/src/apps/playwright.ts
@@ -1,17 +1,17 @@
-import { bool, cleanEnv, email, str, url } from 'envalid';
+import { bool, cleanEnv, url } from 'envalid';
const validated = cleanEnv(process.env, {
CI: bool({ default: false }),
PLAYWRIGHT_BASE_URL: url({
default: process.env.VITE_CONVEX_SITE_URL ?? 'http://127.0.0.1:3211'
}),
- SEED_USER_EMAIL: email({ default: 'demo@groam.example' }),
- SEED_USER_PASSWORD: str({ default: 'GroamDemo123!' })
+ VITE_CONVEX_SITE_URL: url({ default: 'http://127.0.0.1:3211' }),
+ VITE_CONVEX_URL: url({ default: 'http://127.0.0.1:3210' })
});
export const env = {
baseUrl: validated.PLAYWRIGHT_BASE_URL,
- isCI: validated.CI,
- userEmail: validated.SEED_USER_EMAIL,
- userPassword: validated.SEED_USER_PASSWORD
+ convexSiteUrl: validated.VITE_CONVEX_SITE_URL,
+ convexUrl: validated.VITE_CONVEX_URL,
+ isCI: validated.CI
} as const;
diff --git a/tooling/app-actions/package.json b/tooling/app-actions/package.json
index aa430079..4fbdff52 100644
--- a/tooling/app-actions/package.json
+++ b/tooling/app-actions/package.json
@@ -18,7 +18,8 @@
"exports": {
"./backend": "./src/backend/index.ts",
"./ids": "./src/playwright/ids.ts",
- "./playwright": "./src/playwright/index.ts"
+ "./playwright": "./src/playwright/index.ts",
+ "./playwright/test": "./src/playwright/test.ts"
},
"imports": {
"#src/*": "./src/*.ts"
diff --git a/tooling/app-actions/src/backend/add-idea.ts b/tooling/app-actions/src/backend/add-idea.ts
index d52588d2..503fec18 100644
--- a/tooling/app-actions/src/backend/add-idea.ts
+++ b/tooling/app-actions/src/backend/add-idea.ts
@@ -1,19 +1,33 @@
import { api } from '@groam/backend/api';
import type { Id } from '@groam/backend/data-model';
-import type { AddIdeaAction } from '#src/actions/add-idea';
+import type { AddIdeaAction, AddIdeaInput } from '#src/actions/add-idea';
import type { BackendSession } from './backend-session';
import { authenticatedClient } from './convex-client';
import type { AuthenticatedAppUser } from './ensure-user';
-export const addIdea: AddIdeaAction<{
+export type BackendActor = {
backend: BackendSession;
user: AuthenticatedAppUser;
-}> = async ({ backend, user }, input) => {
+};
+
+export type StartedIdea = {
+ proposalId: string;
+ /** The idea's working copy; trip edits made inside the idea target this trip. */
+ workingTripId: string;
+};
+
+export async function startIdea(
+ { backend, user }: BackendActor,
+ input: AddIdeaInput
+): Promise {
if (!input.tripId) throw new Error('The backend addIdea action requires tripId');
const client = await authenticatedClient(backend, user);
const result = await client.mutation(api.routes.trips.versions.create.run, {
ideaName: input.name,
tripId: input.tripId as Id<'trips'>
});
- return result.proposalId;
-};
+ return { proposalId: result.proposalId, workingTripId: result.workingTripId };
+}
+
+export const addIdea: AddIdeaAction = async (actor, input) =>
+ (await startIdea(actor, input)).proposalId;
diff --git a/tooling/app-actions/src/backend/backend-app-actions.ts b/tooling/app-actions/src/backend/backend-app-actions.ts
index 629d7f5f..6f69d12c 100644
--- a/tooling/app-actions/src/backend/backend-app-actions.ts
+++ b/tooling/app-actions/src/backend/backend-app-actions.ts
@@ -16,7 +16,7 @@ import type { UpdateTripInput } from '#src/actions/update-trip';
import { AppActions } from '#src/app-actions';
import { addActivity } from './add-activity';
import { addDestinationToIdea } from './add-destination-to-idea';
-import { addIdea } from './add-idea';
+import { addIdea, startIdea } from './add-idea';
import { addIssueComment } from './add-issue-comment';
import { applyIdea } from './apply-idea';
import { approveIdea } from './approve-idea';
@@ -76,6 +76,8 @@ export class BackendAppActions extends AppActions {
sendMessage({ backend: this.backend, user }, input);
readonly setIssueStatus = (user: AuthenticatedAppUser, input: SetIssueStatusInput) =>
setIssueStatus({ backend: this.backend, user }, input);
+ readonly startIdea = (user: AuthenticatedAppUser, input: AddIdeaInput) =>
+ startIdea({ backend: this.backend, user }, input);
readonly updateTrip = (user: AuthenticatedAppUser, input: UpdateTripInput) =>
updateTrip({ backend: this.backend, user }, input);
diff --git a/tooling/app-actions/src/backend/index.ts b/tooling/app-actions/src/backend/index.ts
index 38ead61d..f6c129bd 100644
--- a/tooling/app-actions/src/backend/index.ts
+++ b/tooling/app-actions/src/backend/index.ts
@@ -1,5 +1,6 @@
export { BackendAppActions, createBackendActions } from './backend-app-actions';
export type { BackendActionsConfig } from './backend-session';
+export type { StartedIdea } from './add-idea';
export type { CreatedTripIds, CreateTripPlan } from './create-trips';
export type { AppUser, AppUserProfile, AuthenticatedAppUser } from './ensure-user';
export type { AppWorkspace, EnsureWorkspaceInput } from './ensure-workspace';
diff --git a/tooling/app-actions/src/playwright/add-group-member-via-invite.ts b/tooling/app-actions/src/playwright/add-group-member-via-invite.ts
deleted file mode 100644
index d2c38a85..00000000
--- a/tooling/app-actions/src/playwright/add-group-member-via-invite.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Page } from '@playwright/test';
-import { acceptGroupInvitation } from './accept-group-invitation';
-import { inviteGroupMember } from './invite-group-member';
-import type { TestUserCredentials } from './unique-test-user';
-
-export type BrowserContextFactory = {
- newContext: () => Promise<{
- close: () => Promise;
- newPage: () => Promise;
- }>;
-};
-
-export async function addGroupMemberViaInvite(
- browser: BrowserContextFactory,
- ownerPage: Page,
- member: TestUserCredentials
-): Promise {
- const invitationCode = await inviteGroupMember(ownerPage);
- const memberContext = await browser.newContext();
- const memberPage = await memberContext.newPage();
- await acceptGroupInvitation(memberPage, invitationCode, member);
- await memberPage.close();
- await memberContext.close();
- await ownerPage.bringToFront();
-}
diff --git a/tooling/app-actions/src/playwright/index.ts b/tooling/app-actions/src/playwright/index.ts
index e03258bc..b6733343 100644
--- a/tooling/app-actions/src/playwright/index.ts
+++ b/tooling/app-actions/src/playwright/index.ts
@@ -20,7 +20,6 @@ export { sendMessage } from './actions/send-message';
export { setIssueStatus } from './actions/set-issue-status';
export { updateTrip } from './actions/update-trip';
export { type AddFirstDestinationInput, addFirstDestination } from './add-first-destination';
-export { addGroupMemberViaInvite, type BrowserContextFactory } from './add-group-member-via-invite';
export { type AddStopInput, addStop } from './add-stop';
export { appHref } from './app-href';
export { approveAndApplyIdea } from './approve-and-apply-idea';
@@ -76,15 +75,18 @@ export {
export { mockDestinationSearch } from './mock-destination-search';
export { openSettingsGroupSection, openSettingsSecuritySection } from './navigation';
export { onePixelPng } from './one-pixel-png';
+export { openApp } from './open-app';
export { openAppPath, reloadAppPage } from './open-app-path';
export { openBackgroundActivity } from './open-background-activity';
export { openChatInbox } from './open-chat-inbox';
export { openCurrentPageOn } from './open-current-page-on';
export { openDestinationDetails } from './open-destination-details';
export { openGroamAssistant } from './open-groam-assistant';
+export { type OpenIdeaInput, openIdea } from './open-idea';
export { openIdeaComparison } from './open-idea-comparison';
export { openIdeaFromList } from './open-idea-from-list';
export { openIdeaItinerary } from './open-idea-itinerary';
+export { openIssue } from './open-issue';
export { openIssueFromInbox } from './open-issue-from-inbox';
export { openIssueFromTrip } from './open-issue-from-trip';
export { type OpenIssueWithStatusInput, openIssueWithStatus } from './open-issue-with-status';
@@ -93,6 +95,7 @@ export { openItinerary } from './open-itinerary';
export { openMobileChat } from './open-mobile-chat';
export { type MobileWorkspaceList, openFirstMobileListRow } from './open-mobile-list-row';
export { openSharedTrip } from './open-shared-trip';
+export { openTrip } from './open-trip';
export { openTripFromList } from './open-trip-from-list';
export { openTripSection, type TripSection } from './open-trip-section';
export { openTrips } from './open-trips';
@@ -133,7 +136,6 @@ export {
showGroamAssistantCapabilities
} from './show-groam-assistant-capabilities';
export { type SharedItineraryExpectation, showSharedItinerary } from './show-shared-itinerary';
-export { signIn } from './sign-in';
export { type SignInCredentials, type SignInOptions, signInAs } from './sign-in-as';
export { signOut } from './sign-out';
export { signUp } from './sign-up';
diff --git a/tooling/app-actions/src/playwright/open-app.ts b/tooling/app-actions/src/playwright/open-app.ts
new file mode 100644
index 00000000..b2e0f2eb
--- /dev/null
+++ b/tooling/app-actions/src/playwright/open-app.ts
@@ -0,0 +1,9 @@
+import type { Page } from '@playwright/test';
+import { expectTripsReady } from './expect-trips-ready';
+import { openAppPath } from './open-app-path';
+
+/** Opens the signed-in app on the trip list, where `signIn` used to land. */
+export async function openApp(page: Page): Promise {
+ await openAppPath(page, 'trips');
+ await expectTripsReady(page);
+}
diff --git a/tooling/app-actions/src/playwright/open-idea.ts b/tooling/app-actions/src/playwright/open-idea.ts
new file mode 100644
index 00000000..1f6ec7c0
--- /dev/null
+++ b/tooling/app-actions/src/playwright/open-idea.ts
@@ -0,0 +1,23 @@
+import { type Page, expect as playwrightExpect } from '@playwright/test';
+import { ids } from './ids';
+import { by, idFromPath } from './locators';
+import { openAppPath } from './open-app-path';
+
+const expect = playwrightExpect.configure({ timeout: 30_000 });
+
+export type OpenIdeaInput = {
+ proposalId: string;
+ tripId: string;
+};
+
+/**
+ * Opens an idea's overview directly, typically one arranged through the backend, and returns
+ * the short ids the app canonicalizes its URL to.
+ */
+export async function openIdea(page: Page, input: OpenIdeaInput): Promise {
+ await openAppPath(page, `trips/${input.tripId}/ideas/${input.proposalId}/overview`);
+ await expect(by(page, ids.ideaWorkspace)).toBeVisible();
+ await expect(by(page, ids.tripHeading)).toBeVisible();
+ await expect(page).toHaveURL(/\/trips\/[a-z]{6}\/ideas\/[a-z]{6}\/overview/u);
+ return { proposalId: idFromPath(page, 'ideas'), tripId: idFromPath(page, 'trips') };
+}
diff --git a/tooling/app-actions/src/playwright/open-issue.ts b/tooling/app-actions/src/playwright/open-issue.ts
new file mode 100644
index 00000000..a2070c1b
--- /dev/null
+++ b/tooling/app-actions/src/playwright/open-issue.ts
@@ -0,0 +1,17 @@
+import { type Page, expect as playwrightExpect } from '@playwright/test';
+import { ids } from './ids';
+import { by, idFromPath } from './locators';
+import { openAppPath } from './open-app-path';
+
+const expect = playwrightExpect.configure({ timeout: 30_000 });
+
+/**
+ * Opens an issue page directly, typically one arranged through the backend, and returns the
+ * short id the app canonicalizes its URL to.
+ */
+export async function openIssue(page: Page, issueId: string): Promise {
+ await openAppPath(page, `issues/${issueId}`);
+ await expect(by(page, ids.issueHeading)).toHaveAttribute('data-issue-id', /.+/u);
+ await expect(page).toHaveURL(/\/issues\/[a-z]{6}(?:[/?#]|$)/u);
+ return idFromPath(page, 'issues');
+}
diff --git a/tooling/app-actions/src/playwright/open-trip.ts b/tooling/app-actions/src/playwright/open-trip.ts
new file mode 100644
index 00000000..45c0591d
--- /dev/null
+++ b/tooling/app-actions/src/playwright/open-trip.ts
@@ -0,0 +1,17 @@
+import { type Page, expect as playwrightExpect } from '@playwright/test';
+import { ids } from './ids';
+import { by, idFromPath } from './locators';
+import { openAppPath } from './open-app-path';
+
+const expect = playwrightExpect.configure({ timeout: 30_000 });
+
+/**
+ * Opens a shared trip directly, typically one arranged through the backend, and returns the
+ * short id the app canonicalizes its URL to.
+ */
+export async function openTrip(page: Page, tripId: string): Promise {
+ await openAppPath(page, `trips/${tripId}`);
+ await expect(by(page, ids.tripHeading)).toBeVisible();
+ await expect(page).toHaveURL(/\/trips\/[a-z]{6}(?:[/?#]|$)/u);
+ return idFromPath(page, 'trips');
+}
diff --git a/tooling/app-actions/src/playwright/sign-in.ts b/tooling/app-actions/src/playwright/sign-in.ts
deleted file mode 100644
index 372328cc..00000000
--- a/tooling/app-actions/src/playwright/sign-in.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { env } from '@groam/env/playwright';
-import type { Page } from '@playwright/test';
-import { signInAs } from './sign-in-as';
-
-export async function signIn(page: Page): Promise {
- await signInAs(page, { email: env.userEmail, password: env.userPassword });
-}
diff --git a/tooling/app-actions/src/playwright/test.ts b/tooling/app-actions/src/playwright/test.ts
new file mode 100644
index 00000000..20cd0d1e
--- /dev/null
+++ b/tooling/app-actions/src/playwright/test.ts
@@ -0,0 +1,95 @@
+import { mkdir } from 'node:fs/promises';
+import path from 'node:path';
+import { test as base, type BrowserContext, type Page } from '@playwright/test';
+import type { CreateIssueInput } from '#src/actions/create-issue';
+import type { CreateTripInput } from '#src/actions/create-trip';
+import type { UpdateTripInput } from '#src/actions/update-trip';
+import type { StartedIdea } from '#src/backend/add-idea';
+import { signInAs } from './sign-in-as';
+import type { TestUserCredentials } from './unique-test-user';
+import {
+ addWorkspaceMember,
+ createE2eBackend,
+ createWorkerAccount,
+ type WorkerAccount
+} from './worker-account';
+
+/** Backend shortcuts for arranging data that a test does not exercise through the UI. */
+export type Arrange = {
+ addMember(prefix?: string): Promise;
+ createIssue(input: CreateIssueInput & { tripId: string }): Promise;
+ createTrip(input: CreateTripInput): Promise;
+ requestIdeaReview(proposalId: string): Promise;
+ startIdea(input: { name: string; tripId: string }): Promise;
+ /** Pass an idea's `workingTripId` to edit the idea instead of the shared trip. */
+ updateTrip(input: UpdateTripInput & { tripId: string }): Promise;
+};
+
+type TestFixtures = {
+ arrange: Arrange;
+ /** Opens a page in a fresh signed-out context; it closes with the test. */
+ newSignedOutPage: () => Promise;
+};
+
+type WorkerFixtures = {
+ account: WorkerAccount;
+ workerStorageState: string;
+};
+
+/**
+ * Playwright `test` whose `page` is already signed in as the worker's own account and group.
+ * Each worker signs in once and reuses that session; specs that exercise sign-up or sign-in
+ * themselves use plain `@playwright/test` instead.
+ */
+export const test = base.extend({
+ account: [
+ // oxlint-disable-next-line no-empty-pattern -- Playwright reads fixture dependencies from this pattern
+ async ({}, provide, workerInfo) => {
+ await provide(await createWorkerAccount(createE2eBackend(), workerInfo.parallelIndex));
+ },
+ { scope: 'worker' }
+ ],
+ arrange: async ({ account }, provide) => {
+ const { backend, user } = account;
+ await provide({
+ addMember: (prefix) => addWorkspaceMember(account, prefix),
+ createIssue: (input) => backend.createIssue(user, input),
+ createTrip: (input) => backend.createTrip(user, input),
+ requestIdeaReview: (proposalId) => backend.requestIdeaReview(user, { proposalId }),
+ startIdea: (input) => backend.startIdea(user, input),
+ updateTrip: (input) => backend.updateTrip(user, input)
+ });
+ },
+ newSignedOutPage: async ({ browser }, provide) => {
+ const contexts: BrowserContext[] = [];
+ await provide(async () => {
+ const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
+ contexts.push(context);
+ return context.newPage();
+ });
+ await Promise.all(contexts.map((context) => context.close()));
+ },
+ storageState: ({ workerStorageState }, provide) => provide(workerStorageState),
+ workerStorageState: [
+ async ({ account, browser }, provide, workerInfo) => {
+ const file = path.join(
+ workerInfo.project.outputDir,
+ '.auth',
+ `worker-${workerInfo.parallelIndex}.json`
+ );
+ await mkdir(path.dirname(file), { recursive: true });
+ const context = await browser.newContext({
+ baseURL: workerInfo.project.use.baseURL,
+ storageState: undefined
+ });
+ const page = await context.newPage();
+ await signInAs(page, account.credentials);
+ await context.storageState({ path: file });
+ await context.close();
+ await provide(file);
+ },
+ { scope: 'worker' }
+ ]
+});
+
+export { expect } from '@playwright/test';
diff --git a/tooling/app-actions/src/playwright/worker-account.ts b/tooling/app-actions/src/playwright/worker-account.ts
new file mode 100644
index 00000000..efa840a8
--- /dev/null
+++ b/tooling/app-actions/src/playwright/worker-account.ts
@@ -0,0 +1,41 @@
+import { env } from '@groam/env/playwright';
+import { type BackendAppActions, createBackendActions } from '#src/backend/backend-app-actions';
+import type { AuthenticatedAppUser } from '#src/backend/ensure-user';
+import type { AppWorkspace } from '#src/backend/ensure-workspace';
+import { type TestUserCredentials, uniqueTestUser } from './unique-test-user';
+
+/** One Playwright worker's own user and group, so parallel workers never share data. */
+export type WorkerAccount = {
+ backend: BackendAppActions;
+ credentials: TestUserCredentials;
+ user: AuthenticatedAppUser;
+ workspace: AppWorkspace;
+};
+
+export function createE2eBackend(): BackendAppActions {
+ return createBackendActions({ convexUrl: env.convexUrl, siteUrl: env.convexSiteUrl });
+}
+
+export async function createWorkerAccount(
+ backend: BackendAppActions,
+ workerIndex: number
+): Promise {
+ const credentials = uniqueTestUser(`worker-${workerIndex}`);
+ const user = await backend.ensureUser(credentials);
+ const workspace = await backend.ensureWorkspace(user, {
+ createIfMissing: true,
+ name: `E2E Worker ${workerIndex} ${credentials.username.slice(-8)}`
+ });
+ return { backend, credentials, user, workspace };
+}
+
+/** Adds a fresh member to the worker's group through the backend instead of the invite UI. */
+export async function addWorkspaceMember(
+ account: WorkerAccount,
+ prefix = 'member'
+): Promise {
+ const credentials = uniqueTestUser(prefix);
+ const member = await account.backend.ensureUser(credentials);
+ await account.backend.joinWorkspace(account.user, member, account.workspace, false);
+ return credentials;
+}
From 8494ade2af798193ae9f6fbc6e482d01f5a94023 Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 13:35:01 +0000
Subject: [PATCH 02/15] Resolve #ai imports when typechecking browser tests
The e2e fixture imports the backend actions, which reach packages/ai
through its #ai subpath imports; map them as the seeder and app-actions
tsconfigs already do.
Co-Authored-By: Claude Opus 5.5
---
apps/web/tsconfig.node.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
index 817ad8b5..50fb78a9 100644
--- a/apps/web/tsconfig.node.json
+++ b/apps/web/tsconfig.node.json
@@ -6,6 +6,7 @@
"noUncheckedSideEffectImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
+ "paths": { "#ai/*": ["../../packages/ai/src/*"] },
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts", "playwright.config.ts", "e2e"]
From 45bc584f30e4d8dfef26d34f436fff5500c55af3 Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 14:02:53 +0000
Subject: [PATCH 03/15] Fail unit tests on undeclared console output
A shared Vitest setup routes console output through a guard: tests declare
output they produce on purpose with expectConsole(), which hides it, and
any other output fails the test. Every Vitest package now loads it.
Turning it on surfaced real problems behind the CI noise:
- Scheduled Idea reviews loaded their context through the signed-in
viewer, so every review failed with "Not authenticated". The review
context now reads the stored proposal directly, like the title agent.
- convex-test ran scheduled functions on real timers, so AI runs and
Wikipedia cover lookups ran behind unrelated tests. Backend tests now
fake setTimeout; agent tests opt back into real timers.
- The geospatial index logged every query at INFO.
Co-Authored-By: Claude Opus 5.5
---
.claude/skills/add-convex-test/SKILL.md | 4 +
.claude/skills/add-web-view-test/SKILL.md | 3 +
.../dashboard/dashboard-view.test.tsx | 4 +-
bun.lock | 5 +
.../shared/assistant-error-boundary.test.tsx | 7 +-
.../modules/assistant/agent/agent.test.ts | 5 +-
.../convex/modules/auth/recovery.test.ts | 3 +
.../discussions/threads/threads.test.ts | 5 +-
.../convex/modules/travel/locations/index.ts | 5 +-
.../travel/versions/agent/review/index.ts | 19 +---
.../convex/modules/travel/versions/index.ts | 30 ++++++
packages/brand/package.json | 1 +
packages/brand/tsconfig.json | 2 +-
packages/brand/vite.config.ts | 1 +
tooling/app-actions/package.json | 1 +
tooling/app-actions/tsconfig.json | 2 +-
tooling/app-actions/vite.config.ts | 1 +
tooling/devkit/package.json | 1 +
tooling/devkit/vite.config.ts | 1 +
tooling/fallow.config.jsonc | 5 +-
tooling/seeder/package.json | 1 +
tooling/seeder/tsconfig.json | 2 +-
tooling/seeder/vite.config.ts | 1 +
tooling/showcase/package.json | 1 +
tooling/showcase/src/storyboard.test.ts | 3 +
tooling/showcase/tsconfig.json | 2 +-
tooling/showcase/vite.config.ts | 1 +
tooling/vitest-config/package.json | 3 +
.../vitest-config/src/console-guard/index.ts | 1 +
.../vitest-config/src/console-guard/setup.ts | 10 ++
.../src/console-guard/state.test.ts | 57 ++++++++++
.../vitest-config/src/console-guard/state.ts | 101 ++++++++++++++++++
tooling/vitest-config/src/convex.ts | 4 +-
tooling/vitest-config/src/fake-scheduler.ts | 5 +
tooling/vitest-config/src/node.ts | 6 ++
tooling/vitest-config/src/setup-files.ts | 8 ++
tooling/vitest-config/src/web.ts | 4 +-
tooling/vitest-config/vite.config.ts | 1 +
vite.config.ts | 3 +-
39 files changed, 286 insertions(+), 33 deletions(-)
create mode 100644 packages/brand/vite.config.ts
create mode 100644 tooling/app-actions/vite.config.ts
create mode 100644 tooling/devkit/vite.config.ts
create mode 100644 tooling/seeder/vite.config.ts
create mode 100644 tooling/showcase/vite.config.ts
create mode 100644 tooling/vitest-config/src/console-guard/index.ts
create mode 100644 tooling/vitest-config/src/console-guard/setup.ts
create mode 100644 tooling/vitest-config/src/console-guard/state.test.ts
create mode 100644 tooling/vitest-config/src/console-guard/state.ts
create mode 100644 tooling/vitest-config/src/fake-scheduler.ts
create mode 100644 tooling/vitest-config/src/node.ts
create mode 100644 tooling/vitest-config/src/setup-files.ts
create mode 100644 tooling/vitest-config/vite.config.ts
diff --git a/.claude/skills/add-convex-test/SKILL.md b/.claude/skills/add-convex-test/SKILL.md
index 17e00c24..b1ebf67e 100644
--- a/.claude/skills/add-convex-test/SKILL.md
+++ b/.claude/skills/add-convex-test/SKILL.md
@@ -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-web-view-test/SKILL.md b/.claude/skills/add-web-view-test/SKILL.md
index 52bd5d81..575ddc6c 100644
--- a/.claude/skills/add-web-view-test/SKILL.md
+++ b/.claude/skills/add-web-view-test/SKILL.md
@@ -80,6 +80,9 @@ 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
diff --git a/apps/web/src/features/dashboard/dashboard-view.test.tsx b/apps/web/src/features/dashboard/dashboard-view.test.tsx
index 93f41200..6ad9c928 100644
--- a/apps/web/src/features/dashboard/dashboard-view.test.tsx
+++ b/apps/web/src/features/dashboard/dashboard-view.test.tsx
@@ -1,3 +1,4 @@
+import { expectConsole } from '@groam/vitest-config/console';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { TripListItem, WorkspaceTripProposal } from '@/features/trips/hooks/use-trips';
@@ -122,7 +123,8 @@ describe('DashboardView', () => {
});
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');
});
diff --git a/bun.lock b/bun.lock
index 7cb26925..ae887e51 100644
--- a/bun.lock
+++ b/bun.lock
@@ -183,6 +183,7 @@
"version": "0.0.0",
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/bun": "^1.3.11",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
@@ -283,6 +284,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
@@ -297,6 +299,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
@@ -312,6 +315,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
@@ -339,6 +343,7 @@
"devDependencies": {
"@ffmpeg-installer/ffmpeg": "1.1.0",
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
diff --git a/packages/ai/src/ui/shared/assistant-error-boundary.test.tsx b/packages/ai/src/ui/shared/assistant-error-boundary.test.tsx
index 13425434..949cf952 100644
--- a/packages/ai/src/ui/shared/assistant-error-boundary.test.tsx
+++ b/packages/ai/src/ui/shared/assistant-error-boundary.test.tsx
@@ -1,5 +1,6 @@
+import { expectConsole } from '@groam/vitest-config/console';
import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
+import { afterEach, expect, test } from 'vite-plus/test';
import { AssistantErrorBoundary } from './assistant-error-boundary';
function BrokenAssistant(): never {
@@ -9,7 +10,8 @@ function BrokenAssistant(): never {
afterEach(cleanup);
test('contains assistant failures instead of taking down the workspace', () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ // React reports the error the boundary catches.
+ expectConsole('error', 'Assistant subscription failed');
const view = render(
Trips remain available
@@ -31,5 +33,4 @@ test('contains assistant failures instead of taking down the workspace', () => {
);
expect(screen.getByText('Assistant recovered')).toBeDefined();
- consoleError.mockRestore();
});
diff --git a/packages/backend/convex/modules/assistant/agent/agent.test.ts b/packages/backend/convex/modules/assistant/agent/agent.test.ts
index f706325d..f207223a 100644
--- a/packages/backend/convex/modules/assistant/agent/agent.test.ts
+++ b/packages/backend/convex/modules/assistant/agent/agent.test.ts
@@ -2,13 +2,16 @@ import { mockModel } from '@convex-dev/agent';
import { chatAgentCallSettings } from '@groam/ai/backend/provider';
import { makeFunctionReference } from 'convex/server';
import { ConvexError, v } from 'convex/values';
-import { expect, test } from 'vite-plus/test';
+import { expect, test, vi } from 'vite-plus/test';
import { api, internal } from '#convex-generated/api';
import type { Id } from '#convex-generated/dataModel';
import { action, env } from '#convex-generated/server';
import { AssistantSession } from '#convex/modules/assistant/agent/index';
import { setupTrip, setupWritableTrip, tripActivityInput, tripLocationInput } from '#testing/trips';
+// Agent replies stream through scheduled functions, so these tests need real timers.
+vi.useRealTimers();
+
const workspaceScreen = {
capabilities: [] as [],
data: '{}',
diff --git a/packages/backend/convex/modules/auth/recovery.test.ts b/packages/backend/convex/modules/auth/recovery.test.ts
index 403b39dc..f92a284b 100644
--- a/packages/backend/convex/modules/auth/recovery.test.ts
+++ b/packages/backend/convex/modules/auth/recovery.test.ts
@@ -1,3 +1,4 @@
+import { expectConsole } from '@groam/vitest-config/console';
import { expect, test } from 'vite-plus/test';
import { components } from '#convex-generated/api';
import { createTestAuthClient } from '#testing/auth/client';
@@ -168,6 +169,8 @@ test('allows only one simultaneous reset from the same recovery set', async () =
]);
expect(results.filter((result) => result.error === null)).toHaveLength(1);
+ // Only one reset wins, so Better Auth warns when the losing password tries to sign in.
+ expectConsole('warn', 'Invalid password');
const signIns = await Promise.all(
passwords.map((password) =>
createTestAuthClient(convex).client.signIn.username({
diff --git a/packages/backend/convex/modules/discussions/threads/threads.test.ts b/packages/backend/convex/modules/discussions/threads/threads.test.ts
index a0d5768f..b0e8f00f 100644
--- a/packages/backend/convex/modules/discussions/threads/threads.test.ts
+++ b/packages/backend/convex/modules/discussions/threads/threads.test.ts
@@ -1,7 +1,7 @@
import { mockModel } from '@convex-dev/agent';
import { makeFunctionReference } from 'convex/server';
import { ConvexError, v } from 'convex/values';
-import { expect, test } from 'vite-plus/test';
+import { expect, test, vi } from 'vite-plus/test';
import { api, internal } from '#convex-generated/api';
import type { Id } from '#convex-generated/dataModel';
import { action } from '#convex-generated/server';
@@ -10,6 +10,9 @@ import { createOutsiderClient } from '#testing/factory';
import { pngBlob } from '#testing/media';
import { setupGroup } from '#testing/trips';
+// Agent replies stream through scheduled functions, so these tests need real timers.
+vi.useRealTimers();
+
const paginationOpts = { cursor: null, numItems: 25 };
// oxlint-disable-next-line jest/no-export -- convex-test discovers test-only actions through module exports.
diff --git a/packages/backend/convex/modules/travel/locations/index.ts b/packages/backend/convex/modules/travel/locations/index.ts
index 1965cae0..c6d53659 100644
--- a/packages/backend/convex/modules/travel/locations/index.ts
+++ b/packages/backend/convex/modules/travel/locations/index.ts
@@ -11,7 +11,10 @@ type LocationFilters = {
type: TripLocationType;
};
-const index = new GeospatialIndex(components.geospatial);
+// The component logs every query at INFO by default.
+const index = new GeospatialIndex(components.geospatial, {
+ logLevel: 'WARN'
+});
function key(type: TripLocationType, id: string): string {
return `${type}:${id}`;
diff --git a/packages/backend/convex/modules/travel/versions/agent/review/index.ts b/packages/backend/convex/modules/travel/versions/agent/review/index.ts
index ac4b4fb6..463c7aad 100644
--- a/packages/backend/convex/modules/travel/versions/agent/review/index.ts
+++ b/packages/backend/convex/modules/travel/versions/agent/review/index.ts
@@ -30,24 +30,7 @@ export const context = internalQuery({
title: v.string(),
workingTripId: v.id('trips')
}),
- handler: async (ctx, { proposalId }) => {
- const proposal = await TripVersions.get(ctx, proposalId);
- if (!proposal.reviewers.some((reviewer) => reviewer.kind === 'agent')) {
- throw new Error('Idea reviewer is not assigned to review this proposal');
- }
- if (proposal.status !== 'in_review' && proposal.status !== 'conflicted') {
- throw new Error('Submit the proposal before requesting idea review');
- }
- const stored = await ctx.db.get('tripProposals', proposalId);
- if (!stored) throw new Error('Proposal not found');
- return {
- changes: proposal.changes,
- organizationId: stored.organizationId,
- sourceTripId: stored.sourceTripId,
- title: proposal.title,
- workingTripId: stored.workingTripId
- };
- }
+ handler: (ctx, { proposalId }) => TripVersions.reviewContext(ctx, proposalId)
});
export const finish = internalMutation({
diff --git a/packages/backend/convex/modules/travel/versions/index.ts b/packages/backend/convex/modules/travel/versions/index.ts
index 91c289d0..7fa0d345 100644
--- a/packages/backend/convex/modules/travel/versions/index.ts
+++ b/packages/backend/convex/modules/travel/versions/index.ts
@@ -1629,6 +1629,35 @@ function tryNormalizeProposalTitle(value: string): string | null {
}
}
+/** What the Idea reviewer reads. Review runs are scheduled, so this needs no user session. */
+async function reviewContext(ctx: QueryCtx, proposalId: Id<'tripProposals'>) {
+ const proposal = await ctx.db.get('tripProposals', proposalId);
+ if (!proposal) throw new ConvexError('Idea trip not found');
+ if (!(proposal.reviewers ?? []).some((reviewer) => reviewer.kind === 'agent')) {
+ throw new Error('Idea reviewer is not assigned to review this proposal');
+ }
+ if (proposal.status !== 'in_review' && proposal.status !== 'conflicted') {
+ throw new Error('Submit the proposal before requesting idea review');
+ }
+ const working = await ctx.db.get('trips', proposal.workingTripId);
+ if (!working) throw new ConvexError('Idea trip not found');
+ const [base, proposed] = await Promise.all([
+ baseSnapshotFor(ctx, proposal),
+ VersionSnapshots.create(ctx, working)
+ ]);
+ return {
+ changes: await VersionPresentation.present(
+ ctx,
+ VersionChanges.diff(base, proposed),
+ proposal.organizationId
+ ),
+ organizationId: proposal.organizationId,
+ sourceTripId: proposal.sourceTripId,
+ title: proposal.title,
+ workingTripId: proposal.workingTripId
+ };
+}
+
async function titleContext(ctx: QueryCtx, proposalId: Id<'tripProposals'>, generation: number) {
const proposal = await ctx.db.get('tripProposals', proposalId);
if (
@@ -1714,6 +1743,7 @@ export class TripVersions {
static prepareResolution = prepareResolution;
static prepareSubmit = prepareSubmit;
static resolveFeedback = resolveFeedback;
+ static reviewContext = reviewContext;
static setApproval = setApproval;
static setReviewers = setReviewers;
static titleContext = titleContext;
diff --git a/packages/brand/package.json b/packages/brand/package.json
index 41533d4b..6bcf5e2b 100644
--- a/packages/brand/package.json
+++ b/packages/brand/package.json
@@ -3,6 +3,7 @@
"version": "0.0.0",
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/bun": "^1.3.11",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
diff --git a/packages/brand/tsconfig.json b/packages/brand/tsconfig.json
index 2328626e..1e5a5714 100644
--- a/packages/brand/tsconfig.json
+++ b/packages/brand/tsconfig.json
@@ -4,5 +4,5 @@
"resolveJsonModule": true,
"types": ["bun", "node", "react", "react-dom"]
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts"]
}
diff --git a/packages/brand/vite.config.ts b/packages/brand/vite.config.ts
new file mode 100644
index 00000000..d1411dbd
--- /dev/null
+++ b/packages/brand/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from '@groam/vitest-config/node';
diff --git a/tooling/app-actions/package.json b/tooling/app-actions/package.json
index 4fbdff52..b878fceb 100644
--- a/tooling/app-actions/package.json
+++ b/tooling/app-actions/package.json
@@ -10,6 +10,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
diff --git a/tooling/app-actions/tsconfig.json b/tooling/app-actions/tsconfig.json
index bb9f773d..9f58cc8f 100644
--- a/tooling/app-actions/tsconfig.json
+++ b/tooling/app-actions/tsconfig.json
@@ -5,5 +5,5 @@
"paths": { "#ai/*": ["../../packages/ai/src/*"], "#src/*": ["./src/*"] },
"types": ["node"]
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts"]
}
diff --git a/tooling/app-actions/vite.config.ts b/tooling/app-actions/vite.config.ts
new file mode 100644
index 00000000..d1411dbd
--- /dev/null
+++ b/tooling/app-actions/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from '@groam/vitest-config/node';
diff --git a/tooling/devkit/package.json b/tooling/devkit/package.json
index 609af1ed..cd59a1d2 100644
--- a/tooling/devkit/package.json
+++ b/tooling/devkit/package.json
@@ -6,6 +6,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
diff --git a/tooling/devkit/vite.config.ts b/tooling/devkit/vite.config.ts
new file mode 100644
index 00000000..d1411dbd
--- /dev/null
+++ b/tooling/devkit/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from '@groam/vitest-config/node';
diff --git a/tooling/fallow.config.jsonc b/tooling/fallow.config.jsonc
index d9b9fd5e..e4da1bb9 100644
--- a/tooling/fallow.config.jsonc
+++ b/tooling/fallow.config.jsonc
@@ -29,7 +29,10 @@
"**/*.spec.tsx",
"**/doctor.config.ts",
// Oxlint loads the Groam lint plugin from tooling/oxc-config/lint.ts by path.
- "tooling/oxc-config/plugin/index.ts"
+ "tooling/oxc-config/plugin/index.ts",
+ // Vitest loads these by path from tooling/vitest-config/src/setup-files.ts.
+ "tooling/vitest-config/src/console-guard/setup.ts",
+ "tooling/vitest-config/src/fake-scheduler.ts"
],
"health": {
"maxCyclomatic": 35,
diff --git a/tooling/seeder/package.json b/tooling/seeder/package.json
index 29945d72..ad363653 100644
--- a/tooling/seeder/package.json
+++ b/tooling/seeder/package.json
@@ -7,6 +7,7 @@
},
"devDependencies": {
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "~7.0.2",
"vite": "catalog:",
diff --git a/tooling/seeder/tsconfig.json b/tooling/seeder/tsconfig.json
index 632896c7..f2d6e955 100644
--- a/tooling/seeder/tsconfig.json
+++ b/tooling/seeder/tsconfig.json
@@ -4,5 +4,5 @@
"lib": ["ES2023", "DOM"],
"paths": { "#ai/*": ["../../packages/ai/src/*"] }
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts"]
}
diff --git a/tooling/seeder/vite.config.ts b/tooling/seeder/vite.config.ts
new file mode 100644
index 00000000..d1411dbd
--- /dev/null
+++ b/tooling/seeder/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from '@groam/vitest-config/node';
diff --git a/tooling/showcase/package.json b/tooling/showcase/package.json
index d68d1b06..7f0251c8 100644
--- a/tooling/showcase/package.json
+++ b/tooling/showcase/package.json
@@ -20,6 +20,7 @@
"devDependencies": {
"@ffmpeg-installer/ffmpeg": "1.1.0",
"@groam/typescript-config": "workspace:*",
+ "@groam/vitest-config": "workspace:*",
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
diff --git a/tooling/showcase/src/storyboard.test.ts b/tooling/showcase/src/storyboard.test.ts
index 78184328..97526751 100644
--- a/tooling/showcase/src/storyboard.test.ts
+++ b/tooling/showcase/src/storyboard.test.ts
@@ -1,3 +1,4 @@
+import { expectConsole } from '@groam/vitest-config/console';
import { describe, expect, it } from 'vite-plus/test';
import { video } from '#showcase';
import {
@@ -108,6 +109,8 @@ describe('storyboard authoring', () => {
});
it('runs readable shots in order with transition time before each action', async () => {
+ // Recording narrates each action so the capture log reads like the script.
+ expectConsole('info', 'A user acts');
const observed: string[] = [];
const director: Director = {
actor() {
diff --git a/tooling/showcase/tsconfig.json b/tooling/showcase/tsconfig.json
index c0b50596..b23b7c99 100644
--- a/tooling/showcase/tsconfig.json
+++ b/tooling/showcase/tsconfig.json
@@ -11,5 +11,5 @@
"#video/*": ["./src/video/*.tsx"]
}
},
- "include": ["src", "video.ts"]
+ "include": ["src", "video.ts", "vite.config.ts"]
}
diff --git a/tooling/showcase/vite.config.ts b/tooling/showcase/vite.config.ts
new file mode 100644
index 00000000..d1411dbd
--- /dev/null
+++ b/tooling/showcase/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from '@groam/vitest-config/node';
diff --git a/tooling/vitest-config/package.json b/tooling/vitest-config/package.json
index 0940c20a..5d98698c 100644
--- a/tooling/vitest-config/package.json
+++ b/tooling/vitest-config/package.json
@@ -9,7 +9,10 @@
"vite-plus": "catalog:"
},
"exports": {
+ "./console": "./src/console-guard/index.ts",
"./convex": "./src/convex.ts",
+ "./node": "./src/node.ts",
+ "./setup-files": "./src/setup-files.ts",
"./web": "./src/web.ts"
},
"license": "MIT",
diff --git a/tooling/vitest-config/src/console-guard/index.ts b/tooling/vitest-config/src/console-guard/index.ts
new file mode 100644
index 00000000..7d21b6a4
--- /dev/null
+++ b/tooling/vitest-config/src/console-guard/index.ts
@@ -0,0 +1 @@
+export { type ConsoleLevel, type ConsolePattern, expectConsole } from './state';
diff --git a/tooling/vitest-config/src/console-guard/setup.ts b/tooling/vitest-config/src/console-guard/setup.ts
new file mode 100644
index 00000000..ac235222
--- /dev/null
+++ b/tooling/vitest-config/src/console-guard/setup.ts
@@ -0,0 +1,10 @@
+import { afterAll, afterEach, beforeEach } from 'vite-plus/test';
+import { finishFile, finishTest, install, startTest } from './state';
+
+install();
+beforeEach(() => {
+ install();
+ startTest();
+});
+afterEach(finishTest);
+afterAll(finishFile);
diff --git a/tooling/vitest-config/src/console-guard/state.test.ts b/tooling/vitest-config/src/console-guard/state.test.ts
new file mode 100644
index 00000000..bcb44d6c
--- /dev/null
+++ b/tooling/vitest-config/src/console-guard/state.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from 'vite-plus/test';
+import { expectConsole, finishFile, finishTest, startTest } from './state';
+
+// Runs `body` as a nested test, then reopens the outer test the live guard is watching.
+const nested = (body: () => void) => () => {
+ startTest();
+ try {
+ body();
+ finishTest();
+ } finally {
+ startTest();
+ }
+};
+
+describe('console guard', () => {
+ test('hides declared output', () => {
+ expect(
+ nested(() => {
+ expectConsole('error', /expected failure/);
+ expectConsole('warn', 'retrying');
+ console.error('an expected failure: %s', 'quota');
+ console.error('another expected failure');
+ console.warn('retrying in 1s');
+ })
+ ).not.toThrow();
+ });
+
+ test('fails on undeclared output', () => {
+ expect(nested(() => console.warn('surprise %d', 1))).toThrow(
+ /Unexpected console output[^\n]*\n {2}console\.warn: surprise 1/
+ );
+ });
+
+ test('matches the level as well as the text', () => {
+ expect(
+ nested(() => {
+ expectConsole('warn', 'disk');
+ console.error('disk full');
+ })
+ ).toThrow(/console\.error: disk full[\s\S]*console\.warn matching disk was never logged/);
+ });
+
+ test('fails when declared output never appears', () => {
+ expect(nested(() => expectConsole('info', /ready/))).toThrow(
+ 'Expected console.info matching /ready/ was never logged'
+ );
+ });
+
+ test('fails the file on output between tests', () => {
+ startTest();
+ finishTest();
+ console.log('after the test ended');
+ expect(() => expectConsole('log', 'late')).toThrow('inside a test or a beforeEach hook');
+ startTest();
+ expect(finishFile).toThrow(/outside a test[\s\S]*console\.log: after the test ended/);
+ });
+});
diff --git a/tooling/vitest-config/src/console-guard/state.ts b/tooling/vitest-config/src/console-guard/state.ts
new file mode 100644
index 00000000..2b7d1671
--- /dev/null
+++ b/tooling/vitest-config/src/console-guard/state.ts
@@ -0,0 +1,101 @@
+import { format } from 'node:util';
+
+export type ConsoleLevel = 'debug' | 'error' | 'info' | 'log' | 'trace' | 'warn';
+export type ConsolePattern = RegExp | string;
+
+type Expectation = { level: ConsoleLevel; pattern: ConsolePattern; seen: boolean };
+type Output = { level: ConsoleLevel; message: string };
+type GuardState = {
+ expectations: Expectation[];
+ inTest: boolean;
+ patched: WeakSet;
+ stray: Output[];
+ unexpected: Output[];
+};
+
+const levels: readonly ConsoleLevel[] = ['debug', 'error', 'info', 'log', 'trace', 'warn'];
+const hint = 'declare it with expectConsole() from @groam/vitest-config/console';
+
+// Setup and test files can load separate copies of this module, so the state lives on the realm.
+const realm = globalThis as { [key: symbol]: GuardState | undefined };
+const state = (realm[Symbol.for('@groam/console-guard')] ??= {
+ expectations: [],
+ inTest: false,
+ patched: new WeakSet(),
+ stray: [],
+ unexpected: []
+});
+
+/**
+ * Declares console output the current test produces on purpose; matching output stays hidden.
+ * Undeclared output fails the test, and so does a declaration nothing matches.
+ */
+export function expectConsole(level: ConsoleLevel, pattern: ConsolePattern): void {
+ if (!state.inTest) {
+ throw new Error('expectConsole() must be called inside a test or a beforeEach hook');
+ }
+ state.expectations.push({ level, pattern, seen: false });
+}
+
+function capture(level: ConsoleLevel, args: unknown[]): void {
+ const message = format(...args);
+ const declared = state.expectations.filter(
+ ({ level: expected, pattern }) =>
+ expected === level &&
+ (typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message))
+ );
+ for (const expectation of declared) expectation.seen = true;
+ if (declared.length === 0) {
+ (state.inTest ? state.unexpected : state.stray).push({ level, message });
+ }
+}
+
+function report(title: string, outputs: Output[]): string[] {
+ if (outputs.length === 0) return [];
+ return [
+ `${title} (${hint}):`,
+ ...outputs.map(({ level, message }) => {
+ const lines = message.split('\n');
+ const more = lines.length > 4 ? '\n …' : '';
+ return ` console.${level}: ${lines.slice(0, 4).join('\n ')}${more}`;
+ })
+ ];
+}
+
+function fail(problems: string[]): void {
+ if (problems.length > 0) throw new Error(problems.join('\n'));
+}
+
+export function install(target: Console = console): void {
+ if (state.patched.has(target)) return;
+ state.patched.add(target);
+ for (const level of levels) target[level] = (...args: unknown[]) => capture(level, args);
+}
+
+export function startTest(): void {
+ state.expectations = [];
+ state.unexpected = [];
+ state.inTest = true;
+}
+
+export function finishTest(): void {
+ const { expectations, unexpected } = state;
+ state.expectations = [];
+ state.unexpected = [];
+ state.inTest = false;
+ fail([
+ ...report('Unexpected console output', unexpected),
+ ...expectations
+ .filter(({ seen }) => !seen)
+ .map(
+ ({ level, pattern }) =>
+ `Expected console.${level} matching ${String(pattern)} was never logged`
+ )
+ ]);
+}
+
+export function finishFile(): void {
+ const { stray } = state;
+ state.stray = [];
+ fail(report('Console output outside a test', stray));
+}
diff --git a/tooling/vitest-config/src/convex.ts b/tooling/vitest-config/src/convex.ts
index 26de3171..e9c81331 100644
--- a/tooling/vitest-config/src/convex.ts
+++ b/tooling/vitest-config/src/convex.ts
@@ -1,4 +1,5 @@
import { env } from '@groam/env/vitest';
+import { setupFiles } from '@groam/vitest-config/setup-files';
import { defineConfig } from 'vite-plus';
export const convexVitestConfig = defineConfig({
@@ -20,6 +21,7 @@ export const convexVitestConfig = defineConfig({
}
},
environment: 'edge-runtime',
- include: ['convex/**/*.test.ts']
+ include: ['convex/**/*.test.ts'],
+ setupFiles: [setupFiles.consoleGuard, setupFiles.fakeScheduler]
}
});
diff --git a/tooling/vitest-config/src/fake-scheduler.ts b/tooling/vitest-config/src/fake-scheduler.ts
new file mode 100644
index 00000000..d5a331fa
--- /dev/null
+++ b/tooling/vitest-config/src/fake-scheduler.ts
@@ -0,0 +1,5 @@
+import { vi } from 'vite-plus/test';
+
+// convex-test runs scheduled functions on setTimeout; faking it keeps background work (AI runs,
+// cover lookups) from running behind unrelated tests. Opt out with `vi.useRealTimers()`.
+vi.useFakeTimers({ toFake: ['clearTimeout', 'setTimeout'] });
diff --git a/tooling/vitest-config/src/node.ts b/tooling/vitest-config/src/node.ts
new file mode 100644
index 00000000..2fee3811
--- /dev/null
+++ b/tooling/vitest-config/src/node.ts
@@ -0,0 +1,6 @@
+import { setupFiles } from '@groam/vitest-config/setup-files';
+import { defineConfig } from 'vite-plus';
+
+export const nodeVitestConfig = defineConfig({
+ test: { setupFiles: [setupFiles.consoleGuard] }
+});
diff --git a/tooling/vitest-config/src/setup-files.ts b/tooling/vitest-config/src/setup-files.ts
new file mode 100644
index 00000000..0391868a
--- /dev/null
+++ b/tooling/vitest-config/src/setup-files.ts
@@ -0,0 +1,8 @@
+import path from 'node:path';
+
+const resolve = (file: string) => path.join(import.meta.dirname, file);
+
+export const setupFiles = {
+ consoleGuard: resolve('console-guard/setup.ts'),
+ fakeScheduler: resolve('fake-scheduler.ts')
+};
diff --git a/tooling/vitest-config/src/web.ts b/tooling/vitest-config/src/web.ts
index 6820efac..29710f21 100644
--- a/tooling/vitest-config/src/web.ts
+++ b/tooling/vitest-config/src/web.ts
@@ -1,4 +1,5 @@
import path from 'node:path';
+import { setupFiles } from '@groam/vitest-config/setup-files';
import { defineConfig } from 'vite-plus';
import { resolveRepoRoot } from './repo-root.ts';
@@ -27,7 +28,8 @@ export function createWebVitestConfig(appRoot: string, options: { projectRoot?:
},
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
- restoreMocks: true
+ restoreMocks: true,
+ setupFiles: [setupFiles.consoleGuard]
}
});
}
diff --git a/tooling/vitest-config/vite.config.ts b/tooling/vitest-config/vite.config.ts
new file mode 100644
index 00000000..babc655a
--- /dev/null
+++ b/tooling/vitest-config/vite.config.ts
@@ -0,0 +1 @@
+export { nodeVitestConfig as default } from './src/node.ts';
diff --git a/vite.config.ts b/vite.config.ts
index 0ac353ab..f6b55475 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,13 @@
import { defineConfig } from 'vite-plus';
import { fmt } from './tooling/oxc-config/fmt.ts';
import { lint } from './tooling/oxc-config/lint.ts';
+import { setupFiles } from './tooling/vitest-config/src/setup-files.ts';
export default defineConfig({
fmt,
lint,
// Root `vp test` runs tooling/quality; its lint fixtures spawn `vp lint` per case.
- test: { testTimeout: 60_000 },
+ test: { setupFiles: [setupFiles.consoleGuard], testTimeout: 60_000 },
staged: {
'*': 'vp check --fix --no-error-on-unmatched-pattern'
}
From e60d01045c59acfff43c6929cdf4bf1082f448d3 Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 14:53:20 +0000
Subject: [PATCH 04/15] Fix trip links that loop on Convex IDs and backend idea
names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trip and idea redirects passed the resolved Convex ID back into the URL
while ReferenceProvider rewrote it to the short ID, so opening
/trips/ looped on "Opening page…". Redirects now keep the
reference as written and let ReferenceProvider canonicalize it once.
The backend addIdea action now names ideas by title, as the UI does, and
openSharedTrip waits for the canonical URL like the other open helpers.
Co-Authored-By: Claude Opus 5.5
---
.../ideas/review/revoke-idea-approval.spec.ts | 4 +++-
.../_workspace.trips.$tripId.$section.tsx | 22 ++++++++++++++-----
....trips.$tripId.ideas.$proposalId.$view.tsx | 5 +++--
....trips.$tripId.ideas.$proposalId.index.tsx | 4 ++--
.../routes/_workspace.trips.$tripId.index.tsx | 4 ++--
tooling/app-actions/src/backend/add-idea.ts | 2 +-
.../src/playwright/open-shared-trip.ts | 1 +
7 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
index c8bc1267..189dd808 100644
--- a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
+++ b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
@@ -3,6 +3,7 @@ import {
by,
ids,
openIdea,
+ openIdeaComparison,
openIdeaFromList,
revokeIdeaApproval,
signInAs,
@@ -18,17 +19,18 @@ test('revoking the only approval prevents an idea from being applied', async ({
test.setTimeout(180_000);
const suffix = uniqueSuffix();
const ideaName = `Approval reversal ${suffix}`;
- const reviewerAccount = await arrange.addMember('approval-reversal');
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 reviewerAccount = await arrange.addMember('approval-reversal');
const reviewer = await newSignedOutPage();
await signInAs(reviewer, reviewerAccount);
await openIdeaFromList(reviewer, ideaName);
await approveIdea(reviewer);
await openIdea(page, { proposalId: idea.proposalId, tripId });
+ await openIdeaComparison(page);
await expect(by(page, ids.applyIdea).first()).toBeEnabled();
await revokeIdeaApproval(reviewer);
diff --git a/apps/web/src/routes/_workspace.trips.$tripId.$section.tsx b/apps/web/src/routes/_workspace.trips.$tripId.$section.tsx
index b1183189..e79ba824 100644
--- a/apps/web/src/routes/_workspace.trips.$tripId.$section.tsx
+++ b/apps/web/src/routes/_workspace.trips.$tripId.$section.tsx
@@ -11,7 +11,9 @@ export const Route = createFileRoute('/_workspace/trips/$tripId/$section')({
});
function TripSectionRoute() {
- const { section, tripId } = useResolvedParams(Route.useParams());
+ // Redirects keep the reference as written; ReferenceProvider canonicalizes it once.
+ const params = Route.useParams();
+ const { section, tripId } = useResolvedParams(params);
const { addDestination = false, issue, proposal } = Route.useSearch();
if (section === 'issues' && issue) {
@@ -21,7 +23,7 @@ function TripSectionRoute() {
if (section === 'versions' && proposal) {
return (
@@ -29,13 +31,19 @@ function TripSectionRoute() {
}
if (section === 'versions') {
- return ;
+ return (
+
+ );
}
if (section === 'ideas' && proposal) {
return (
@@ -44,7 +52,11 @@ function TripSectionRoute() {
if (section === 'comments' || section === 'decisions' || !isTripSection(section)) {
return (
-
+
);
}
diff --git a/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.$view.tsx b/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.$view.tsx
index 93ab8a29..4eb0ca36 100644
--- a/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.$view.tsx
+++ b/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.$view.tsx
@@ -11,13 +11,14 @@ export const Route = createFileRoute('/_workspace/trips/$tripId/ideas/$proposalI
});
function IdeaCloneSectionRoute() {
- const { proposalId, tripId, view } = useResolvedParams(Route.useParams());
+ const params = Route.useParams();
+ const { proposalId, tripId, view } = useResolvedParams(params);
const search = Route.useSearch();
if (!isIdeaCloneView(view)) {
return (
diff --git a/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.index.tsx b/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.index.tsx
index 7ce37aa9..8240cc99 100644
--- a/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.index.tsx
+++ b/apps/web/src/routes/_workspace.trips.$tripId.ideas.$proposalId.index.tsx
@@ -1,13 +1,13 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { defaultIdeaCloneView } from '@/features/ideas/idea-clone/idea-sections';
-import { useResolvedParams } from '@/features/workspace/hooks/reference-context';
export const Route = createFileRoute('/_workspace/trips/$tripId/ideas/$proposalId/')({
component: IdeaCloneIndexRoute
});
function IdeaCloneIndexRoute() {
- const { proposalId, tripId } = useResolvedParams(Route.useParams());
+ // Keep the references as written; ReferenceProvider canonicalizes them once.
+ const { proposalId, tripId } = Route.useParams();
return (
;
}
diff --git a/tooling/app-actions/src/backend/add-idea.ts b/tooling/app-actions/src/backend/add-idea.ts
index 503fec18..49cb32fc 100644
--- a/tooling/app-actions/src/backend/add-idea.ts
+++ b/tooling/app-actions/src/backend/add-idea.ts
@@ -23,7 +23,7 @@ export async function startIdea(
if (!input.tripId) throw new Error('The backend addIdea action requires tripId');
const client = await authenticatedClient(backend, user);
const result = await client.mutation(api.routes.trips.versions.create.run, {
- ideaName: input.name,
+ title: input.name,
tripId: input.tripId as Id<'trips'>
});
return { proposalId: result.proposalId, workingTripId: result.workingTripId };
diff --git a/tooling/app-actions/src/playwright/open-shared-trip.ts b/tooling/app-actions/src/playwright/open-shared-trip.ts
index 81b711cf..5140c240 100644
--- a/tooling/app-actions/src/playwright/open-shared-trip.ts
+++ b/tooling/app-actions/src/playwright/open-shared-trip.ts
@@ -9,4 +9,5 @@ export async function openSharedTrip(target: UiTarget): Promise {
const user = ui(target);
await user.click(by(user.page, ids.ideaOpenShared));
await expect(by(user.page, ids.tripHeading)).toBeVisible();
+ await expect(user.page).toHaveURL(/\/trips\/[a-z]{6}(?:[/?#]|$)/u);
}
From de7159b2fbf29e0dddc5d96c07bcf477dbf2ab1b Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 15:26:15 +0000
Subject: [PATCH 05/15] Run CI checks as parallel jobs without browser shards
Lint, typecheck, unit tests, browser tests, and desktop builds now start
together as separate jobs instead of waiting on one quality job, and the
browser suite runs as a single job. The showcase recording test declares
its progress output, and createTrip waits for the trip list to load before
opening the dialog.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/test-reusable.yml | 84 +++++++++++++------
.../src/playwright/actions/create-trip.ts | 2 +
tooling/showcase/src/capture/record.test.ts | 4 +
3 files changed, 66 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml
index fc288eef..6f6f1c73 100644
--- a/.github/workflows/test-reusable.yml
+++ b/.github/workflows/test-reusable.yml
@@ -16,10 +16,10 @@ env:
TURBO_TELEMETRY_DISABLED: '1'
jobs:
- quality:
- name: Quality
+ lint:
+ name: 🧹 Lint
runs-on: ubuntu-latest
- timeout-minutes: 60
+ timeout-minutes: 30
env:
CI: 'true'
steps:
@@ -44,22 +44,61 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: Format check
+ - name: 📐 Format
run: bun run format:check
- - name: Lint, typecheck, and unit tests
- run: bun run check
+ - name: 🔍 Lint
+ run: bun run lint
+
+ - name: 🛡️ Audit dependencies
+ run: bun run audit
+
+ - name: 🩺 React Doctor
+ run: bun run react-doctor
- e2e:
- name: Browser (${{ matrix.shard }}/2)
- needs: [quality]
+ - name: 🪦 Dead code
+ run: bun run fallow
+
+ typecheck:
+ name: 🔷 Typecheck
runs-on: ubuntu-latest
- timeout-minutes: 90
- strategy:
- fail-fast: false
- matrix:
- # Each shard runs two Playwright workers, each with its own account and group.
- shard: [1, 2]
+ timeout-minutes: 30
+ env:
+ CI: 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Setup workspace
+ uses: ./.github/actions/setup-workspace
+
+ - name: 🔷 Typecheck
+ run: bun run typecheck
+
+ unit:
+ name: 🧪 Unit tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ CI: 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Setup workspace
+ uses: ./.github/actions/setup-workspace
+
+ - name: 🧪 Unit tests
+ run: bun run test
+
+ browser:
+ name: 🎭 Browser
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
env:
CI: 'true'
CONVEX_AGENT_MODE: anonymous
@@ -86,7 +125,7 @@ jobs:
- name: Install Playwright browser
run: bun run test:e2e:install --with-deps
- - name: Test showcase recording
+ - name: 🎬 Showcase recording
run: bun run --cwd tooling/showcase test:browser
- name: Start local Convex backend
@@ -103,9 +142,7 @@ jobs:
bunx convex env set BETTER_AUTH_SECRET "ci-only-better-auth-secret-not-for-production"
bunx convex env set SITE_URL "$PLAYWRIGHT_BASE_URL/"
- # Generated code is identical in every shard; verifying it once is enough.
- - name: Verify generated code
- if: matrix.shard == 1
+ - name: ⚙️ Verify generated code
run: |
bun run codegen
git diff --exit-code
@@ -123,14 +160,14 @@ jobs:
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: Run browser tests
- run: bun run test:e2e -- --shard=${{ matrix.shard }}/2
+ - name: 🎭 Browser tests
+ run: bun run test:e2e
- 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
retention-days: 7
path: |
@@ -147,8 +184,7 @@ jobs:
fi
desktop:
- name: Desktop (${{ matrix.platform }})
- needs: [quality]
+ name: 🖥️ Desktop (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
diff --git a/tooling/app-actions/src/playwright/actions/create-trip.ts b/tooling/app-actions/src/playwright/actions/create-trip.ts
index 687088cf..de09f51c 100644
--- a/tooling/app-actions/src/playwright/actions/create-trip.ts
+++ b/tooling/app-actions/src/playwright/actions/create-trip.ts
@@ -14,6 +14,8 @@ export async function createTrip(target: UiTarget, input: CreateTripInput): Prom
await user.click(by(page, ids.navTrips));
await expect(by(page, ids.tripsTitle)).toBeVisible();
const emptyState = by(page, ids.createTripEmpty);
+ // Wait for the list to load; opening the dialog earlier loses it when the list re-renders.
+ await expect(emptyState.or(by(page, ids.tripCard)).first()).toBeVisible();
await user.click((await emptyState.isVisible()) ? emptyState : createTripButton(page));
await expect(by(page, ids.createTripDialog)).toBeVisible();
await user.type(by(page, ids.createTripName), tripName);
diff --git a/tooling/showcase/src/capture/record.test.ts b/tooling/showcase/src/capture/record.test.ts
index 5c40eac8..ff07822b 100644
--- a/tooling/showcase/src/capture/record.test.ts
+++ b/tooling/showcase/src/capture/record.test.ts
@@ -2,12 +2,16 @@ import { mkdtemp, readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import { expectConsole } from '@groam/vitest-config/console';
import { errors, expect as expectPage } from '@playwright/test';
import { expect, it, vi } from 'vite-plus/test';
import { assertPngSize } from '#src/capture/png';
import { recordScenario } from '#src/capture/record';
it('captures two isolated browsers observing the same update and finalizes every frame', async () => {
+ // Recording reports its progress.
+ expectConsole('info', 'Preparing 1920×1080 desktops');
+ expectConsole('info', 'Recording: Plan together');
let shared = '';
let screenshotAttempts = 0;
const server = createServer((request, response) => {
From 04cad096631fc6e73c05a1277bbd8f88443de804 Mon Sep 17 00:00:00 2001
From: Nicholas Santi
Date: Thu, 15 Oct 2026 18:20:54 +0000
Subject: [PATCH 06/15] Move every test to Vite+ browser mode
Component and view tests run in real Chromium through the `views` setup of
`vp test` (page locators, retrying expect.element) instead of jsdom and
Testing Library. Browser e2e becomes the `journeys` setup: the real app
against the local backend, driven by @groam/app-actions/vitest, with backend
arrangements through Vitest browser commands. Playwright Test, jsdom, and
Testing Library are gone; the showcase keeps Playwright for recording.
Real browsers surfaced app fixes: per-group workspace remounts (no stale
pagination cursors), passkeys trusting every local origin on their host, a
single accessible "Attach files" control, labelled issue assignee rows, and
no React warning from the theme script.
Co-Authored-By: Claude Opus 5.5
---
.claude/agents/qa.md | 6 +-
.claude/skills/add-env-variable/SKILL.md | 2 +-
.claude/skills/add-test-id/SKILL.md | 26 +-
.claude/skills/add-web-view-test/SKILL.md | 52 ++-
.devcontainer/post-create.sh | 6 +-
.github/actions/setup-chromium/action.yml | 17 +
.github/workflows/test-reusable.yml | 61 ++-
CLAUDE.md | 7 +-
Justfile | 11 +-
README.md | 2 +-
apps/web/e2e/assistant/open-assistant.spec.ts | 9 -
.../web/e2e/assistant/open-assistant.test.tsx | 18 +
apps/web/e2e/auth/account-security.spec.ts | 29 --
apps/web/e2e/auth/account-security.test.tsx | 37 ++
.../auth/sign-up-and-create-a-group.spec.ts | 27 --
.../auth/sign-up-and-create-a-group.test.tsx | 26 ++
apps/web/e2e/chat/chat-reaction.spec.ts | 33 --
apps/web/e2e/chat/chat-reaction.test.tsx | 29 ++
apps/web/e2e/chat/chat-with-the-group.spec.ts | 22 -
.../web/e2e/chat/chat-with-the-group.test.tsx | 21 +
apps/web/e2e/group/invite-a-member.spec.ts | 60 ---
apps/web/e2e/group/invite-a-member.test.tsx | 64 +++
apps/web/e2e/group/switch-groups.spec.ts | 35 --
apps/web/e2e/group/switch-groups.test.tsx | 35 ++
.../activities/apply-idea-activity.spec.ts | 61 ---
.../activities/apply-idea-activity.test.tsx | 54 +++
.../cancel-idea-activity-edit.spec.ts | 47 --
.../activities/edit-idea-activities.spec.ts | 136 ------
.../activities/edit-idea-activities.test.tsx | 89 ++++
.../edit-multiday-idea-activity.spec.ts | 39 --
.../activities/inline-idea-activities.spec.ts | 106 -----
.../activities/undated-idea-activity.spec.ts | 34 --
.../activities/undated-idea-activity.test.tsx | 34 ++
.../destination-details-locator.spec.ts | 25 --
.../e2e/ideas/planning/idea-layout.spec.ts | 95 ----
.../planning/plan-a-trip-itinerary.spec.ts | 52 ---
.../planning/plan-a-trip-itinerary.test.tsx | 53 +++
.../e2e/ideas/planning/start-an-idea.spec.ts | 39 --
.../e2e/ideas/planning/start-an-idea.test.tsx | 40 ++
.../start-idea-from-workspace.spec.ts | 28 --
.../start-idea-from-workspace.test.tsx | 29 ++
.../start-with-first-destination.spec.ts | 36 --
.../start-with-first-destination.test.tsx | 37 ++
apps/web/e2e/ideas/review/close-idea.spec.ts | 39 --
apps/web/e2e/ideas/review/close-idea.test.tsx | 43 ++
...-gate.spec.ts => idea-apply-gate.test.tsx} | 25 +-
.../idea-review-requires-change.spec.ts | 24 --
...s.spec.ts => review-idea-changes.test.tsx} | 25 +-
.../review/review-idea-permissions.spec.ts | 64 ---
.../review/review-idea-permissions.test.tsx | 63 +++
.../review/reviewer-layout.visual.spec.ts | 12 -
.../ideas/review/revoke-idea-approval.spec.ts | 42 --
.../review/revoke-idea-approval.test.tsx | 37 ++
apps/web/e2e/issues/create-an-issue.spec.ts | 60 ---
apps/web/e2e/issues/create-an-issue.test.tsx | 60 +++
.../issues/issue-comment-persistence.spec.ts | 27 --
.../issues/issue-comment-persistence.test.tsx | 30 ++
apps/web/e2e/issues/reopen-issue.spec.ts | 38 --
apps/web/e2e/issues/reopen-issue.test.tsx | 38 ++
...esolve-an-issue-with-a-merged-idea.spec.ts | 54 ---
...solve-an-issue-with-a-merged-idea.test.tsx | 45 ++
.../web/e2e/trips/archive-trip-filter.spec.ts | 34 --
.../e2e/trips/archive-trip-filter.test.tsx | 34 ++
apps/web/e2e/trips/create-a-trip.spec.ts | 36 --
apps/web/e2e/trips/create-a-trip.test.tsx | 30 ++
.../resolve-trip-detail-conflicts.spec.ts | 72 ----
.../resolve-trip-detail-conflicts.test.tsx | 77 ++++
apps/web/e2e/trips/trip-search.spec.ts | 22 -
apps/web/e2e/trips/trip-search.test.tsx | 19 +
.../e2e/workspace/browse-workspace.spec.ts | 86 ----
.../e2e/workspace/browse-workspace.test.tsx | 44 ++
...spec.ts => workspace-idea-search.test.tsx} | 30 +-
...pec.ts => workspace-issue-search.test.tsx} | 30 +-
apps/web/package.json | 11 +-
apps/web/playwright.config.ts | 21 -
apps/web/src/app-root.tsx | 18 +
apps/web/src/browser-setup.ts | 1 +
.../agent-detail/agent-detail-view.test.tsx | 58 +--
.../agent-list/agents-list-view.test.tsx | 195 ++++++---
.../agents/agent-run/agent-run-log.test.tsx | 75 ++--
.../agent-run/agent-run-report.test.tsx | 25 +-
.../agents/agent-run/agent-run-table.test.tsx | 42 +-
.../agents/agent-run/agent-run-view.test.tsx | 311 +++++++-------
.../agents/agents-sidebar-nav.test.tsx | 31 +-
.../agents/hooks/use-agent-roster.test.ts | 13 +-
.../agents/hooks/use-agent-run.test.ts | 25 +-
.../features/agents/hooks/use-agent.test.ts | 17 +-
.../hooks/use-workspace-agent-runs.test.ts | 17 +-
.../web/src/features/auth/auth-client-mock.ts | 32 +-
.../src/features/auth/auth-screen.test.tsx | 89 ++--
.../auth/hooks/use-auth-form.test.tsx | 95 ++--
.../dashboard/dashboard-view.test.tsx | 125 ++++--
.../dashboard/hooks/use-dashboard.test.tsx | 10 +-
.../discussions/chat/chat-workspace.test.tsx | 38 +-
.../discussions/chats-sidebar-nav.test.tsx | 67 ++-
.../create-discussion-dialog.test.tsx | 55 +--
.../discussion-thread-composer.test.tsx | 64 ++-
.../discussion-thread-header.test.tsx | 125 +++---
.../discussion-thread-view.test.tsx | 50 +--
.../discussion-thread/thread-message.test.tsx | 74 ++--
.../use-discussion-message-actions.test.tsx | 23 +-
.../hooks/use-discussion-thread.test.tsx | 45 +-
.../hooks/use-discussions.test.tsx | 17 +-
.../hooks/use-thread-message-chrome.test.tsx | 12 +-
.../hooks/use-thread-message-edit.test.tsx | 28 +-
.../invitations-section.test.tsx | 33 +-
.../group-people-settings.test.tsx | 27 +-
.../group/hooks/use-group-actions.test.tsx | 18 +-
.../group/hooks/use-invitation-codes.test.tsx | 25 +-
.../hooks/use-idea-clone-workspace.test.tsx | 53 +--
.../ideas/hooks/use-idea-context.test.tsx | 27 +-
.../hooks/use-idea-list-filters.test.tsx | 24 +-
.../hooks/use-start-idea-dialog.test.tsx | 23 +-
.../ideas/hooks/use-workspace-ideas.test.ts | 21 +-
.../idea-clone-header-actions.test.tsx | 78 ++--
.../ideas/idea-clone/idea-clone-nav.test.tsx | 33 +-
.../idea-clone-update-banner.test.tsx | 19 +-
.../idea-clone/idea-copy-explainer.test.tsx | 23 +-
.../idea-workspace-heading.test.tsx | 17 +-
.../idea-detail/idea-detail-view.test.tsx | 35 +-
.../ideas/idea-list/idea-list-view.test.tsx | 89 ++--
.../ideas/idea-list/idea-list.test.tsx | 125 ++++--
.../features/ideas/ideas-sidebar-nav.test.tsx | 89 ++--
.../hooks/use-workspace-notifications.test.ts | 15 +-
.../hooks/use-issue-list-filters.test.tsx | 22 +-
.../hooks/use-issues-agent-context.test.tsx | 19 +-
.../hooks/use-workspace-issues.test.tsx | 19 +-
.../issue-detail/issue-agent-banner.test.tsx | 34 +-
.../issue-detail/issue-detail-view.test.tsx | 56 +--
.../issues/issue-detail/issue-detail.test.tsx | 197 ++++-----
.../issues/issue-detail/issue-sidebar.tsx | 2 +
.../issue-list/issue-list-view.test.tsx | 64 +--
.../issues/issue-list/issue-list.test.tsx | 86 ++--
.../issues/issues-sidebar-nav.test.tsx | 59 ++-
.../media/hooks/use-media-upload.test.tsx | 30 +-
.../use-account-recovery-settings.test.tsx | 6 +-
.../hooks/use-ai-availability.test.tsx | 17 +-
.../hooks/use-ai-provider-auth.test.tsx | 27 +-
.../settings/hooks/use-ai-settings.test.tsx | 69 +--
.../hooks/use-organization-logo.test.tsx | 13 +-
.../hooks/use-organization-settings.test.tsx | 22 +-
.../hooks/use-passkey-settings.test.tsx | 30 +-
.../hooks/use-password-settings.test.tsx | 18 +-
.../hooks/use-profile-settings.test.tsx | 8 +-
.../hooks/use-session-settings.test.tsx | 22 +-
.../hooks/use-two-factor-settings.test.tsx | 12 +-
.../account-recovery-settings.test.tsx | 29 +-
.../settings-panels/ai-settings.test.tsx | 56 ++-
.../appearance-settings.test.tsx | 74 ++++
.../settings-panels/data-settings.test.tsx | 17 +-
.../settings-panels/profile-settings.test.tsx | 31 +-
.../settings-panels/session-settings.test.tsx | 31 +-
.../settings-shell/settings-panel.test.tsx | 51 +--
.../settings-shell/settings-view.test.tsx | 54 +--
.../trips/expect-route-stop-photos.ts | 17 +-
.../use-create-trip-idea-dialog.test.tsx | 18 +-
.../use-ensure-destination-covers.test.tsx | 17 +-
.../hooks/use-ensure-trip-cover.test.tsx | 17 +-
.../use-idea-rebase-reconciliation.test.ts | 26 +-
.../use-itinerary-details-resolve.test.tsx | 19 +-
.../use-itinerary-proposal-changes.test.tsx | 15 +-
.../hooks/use-proposal-action-runner.test.tsx | 8 +-
.../trips/hooks/use-review-comment.test.tsx | 18 +-
.../trips/hooks/use-start-idea-flow.test.tsx | 51 +--
.../hooks/use-trip-activity-editor.test.tsx | 22 +-
.../hooks/use-trip-activity-filters.test.tsx | 14 +-
.../hooks/use-trip-agent-context.test.tsx | 35 +-
.../use-trip-destination-search.test.tsx | 28 +-
.../hooks/use-trip-duration-planner.test.tsx | 8 +-
.../trips/hooks/use-trip-issues.test.tsx | 25 +-
.../hooks/use-trip-list-auto-load.test.tsx | 39 +-
.../hooks/use-trip-list-filters.test.tsx | 20 +-
.../trips/hooks/use-trip-packing.test.tsx | 23 +-
.../trips/hooks/use-trip-record.test.tsx | 15 +-
.../hooks/use-trip-sidebar-actions.test.tsx | 15 +-
.../trips/hooks/use-trip-stay-editor.test.tsx | 26 +-
.../hooks/use-trip-transfer-form.test.tsx | 46 +-
.../trips/hooks/use-trip-travelers.test.tsx | 13 +-
.../trips/hooks/use-trip-versions.test.tsx | 29 +-
.../trips/hooks/use-trips-sidebar-ui.test.tsx | 20 +-
.../features/trips/hooks/use-trips.test.tsx | 25 +-
.../editor-destination-photo.test.tsx | 27 +-
.../itinerary-editor.test.tsx | 405 ++++++++++++++++++
.../itinerary-details-resolve-sheet.test.tsx | 57 ++-
.../itinerary-proposal-highlight.test.tsx | 79 ++--
.../features/trips/test-workspace-mocks.tsx | 42 +-
.../trip-activity/trip-activity-card.test.tsx | 37 +-
.../trip-activity-fields.test.tsx | 28 +-
.../trip-activity-history.test.tsx | 70 +--
.../trip-create/create-trip-dialog.test.tsx | 172 +++++---
.../destination-stop-marker.test.tsx | 52 ++-
.../destination-travel-leg.test.tsx | 61 ++-
.../scheduled-destination-days.test.tsx | 47 +-
.../trip-destinations-panel.test.tsx | 301 ++++++-------
.../trip-route-summary.test.tsx | 42 +-
.../trip-detail/shared-trip-banner.test.tsx | 29 +-
.../trip-detail/trip-detail-view.test.tsx | 61 +--
.../trips/trip-detail/trip-loading.test.tsx | 23 +-
.../trip-navigation-actions.test.tsx | 28 +-
.../trips/trip-detail/trip-page-hero.test.tsx | 97 +++--
.../trip-detail/trip-page-shell.test.tsx | 228 ++++++++++
.../trip-detail/trip-section-nav.test.tsx | 30 +-
.../trip-forms/trip-information-fields.tsx | 1 +
.../create-trip-idea-dialog.test.tsx | 61 +--
.../trip-ideas/create-trip-idea-dialog.tsx | 2 +-
.../idea-rebase-reconciliation.test.tsx | 109 ++---
.../trips/trip-ideas/trip-idea-name.test.tsx | 15 +-
.../trip-ideas/trip-idea-notice.test.tsx | 15 +-
.../trip-ideas/trip-ideas-panel.test.tsx | 53 +--
.../trip-issues/trip-issues-panel.test.tsx | 38 +-
.../trips/trip-list/trip-list-view.test.tsx | 93 ++--
.../overview-properties.test.tsx | 58 +--
.../trip-duration-planner.test.tsx | 87 ++--
.../trip-overview/trip-overview.test.tsx | 149 ++++---
.../trip-overview/trip-packing-list.test.tsx | 54 ++-
.../trip-planner/trip-day-planner.test.tsx | 45 +-
.../transport-mode-picker.test.tsx | 19 +-
.../trip-travelers-sheet.test.tsx | 25 +-
.../trip-versions/apply-idea-dialog.test.tsx | 25 +-
.../change-comment-composer.test.tsx | 39 +-
.../trip-versions/change-summary.test.tsx | 151 ++++++-
.../trip-versions/decision-actions.test.tsx | 91 ++++
.../trips/trip-versions/pass-on-idea.test.tsx | 44 +-
.../trip-versions-panel.test.tsx | 38 +-
.../features/trips/trips-sidebar-nav.test.tsx | 50 +--
.../hooks/use-async-pending.test.tsx | 8 +-
.../hooks/use-command-palette.test.tsx | 19 +-
.../hooks/use-invite-dialog.test.tsx | 15 +-
.../workspace/hooks/use-join-group.test.tsx | 27 +-
.../workspace/hooks/use-open-state.test.tsx | 12 +-
.../use-organization-activation.test.tsx | 8 +-
.../hooks/use-query-status-filter.test.tsx | 10 +-
.../hooks/use-reference-params.test.tsx | 22 +-
.../hooks/use-request-state.test.tsx | 8 +-
.../hooks/use-sidebar-nested-paging.test.tsx | 14 +-
.../hooks/use-sidebar-nested-visible.test.tsx | 10 +-
.../hooks/use-sidebar-section-open.test.tsx | 16 +-
.../hooks/use-workspace-data.test.tsx | 26 +-
.../navigation/reference-navigation.test.tsx | 55 +--
.../command-palette.test.tsx | 18 +-
.../composer-timeline-badge.test.tsx | 16 +-
.../workspace-assistant-widget.test.tsx | 45 +-
.../workspace-context.test.tsx | 49 ++-
.../workspace-shell/workspace-context.tsx | 10 +-
.../workspace-dialog-state.test.tsx | 18 +-
.../workspace-shell/workspace-shell.test.tsx | 126 ++++++
.../sidebar-collapsible-item.test.tsx | 70 ++-
apps/web/src/lib/test-mocks/dropdown-menu.ts | 17 -
apps/web/src/lib/use-ready-value.test.tsx | 38 +-
apps/web/src/main.tsx | 16 +-
apps/web/src/router.tsx | 22 +-
.../src/testing/itinerary-editor-fixture.tsx | 147 +++++++
apps/web/src/testing/open-app.tsx | 69 +++
apps/web/src/testing/stub-match-media.ts | 18 -
apps/web/src/views-setup.ts | 7 +
apps/web/tsconfig.app.json | 11 +-
apps/web/tsconfig.node.json | 2 +-
apps/web/vite.config.ts | 65 ++-
bun.lock | 27 +-
package.json | 7 +-
packages/ai/package.json | 2 -
packages/ai/src/browser-setup.css | 2 +
packages/ai/src/browser-setup.ts | 1 +
.../assistant-attachments.test.tsx | 40 +-
.../src/ui/chat/ai-assistant-widget.test.tsx | 185 ++++----
.../ui/chat/assistant-conversation.test.tsx | 183 ++++----
.../ai/src/ui/context/agent-context.test.tsx | 43 +-
packages/ai/src/ui/form/recap.test.tsx | 13 +-
.../src/ui/hooks/use-assistant-chats.test.tsx | 8 +-
.../ai/src/ui/hooks/use-assistant.test.tsx | 42 +-
.../ui/messages/assistant-message.test.tsx | 14 +-
.../src/ui/messages/failed-response.test.tsx | 29 +-
.../src/ui/messages/system-message.test.tsx | 14 +-
.../ai/src/ui/messages/user-message.test.tsx | 20 +-
.../ui/runs/agent-retry-from-zero.test.tsx | 20 +-
.../ai/src/ui/runs/agent-run-banner.test.tsx | 80 ++--
.../shared/assistant-error-boundary.test.tsx | 21 +-
.../assistant-choice-receipt.test.tsx | 19 +-
packages/ai/vite.config.ts | 8 +-
packages/auth/package.json | 1 -
packages/auth/src/client.ts | 10 +
packages/auth/vite.config.ts | 5 +-
packages/backend/convex/modules/auth/auth.ts | 11 +-
.../convex/modules/auth/origins.test.ts | 7 +-
.../backend/convex/modules/auth/origins.ts | 11 +-
.../convex/modules/travel/versions/index.ts | 42 +-
.../modules/travel/versions/versions.test.ts | 21 +
packages/env/README.md | 4 +-
packages/env/package.json | 2 +-
.../src/apps/{playwright.ts => journeys.ts} | 4 -
packages/ui/package.json | 3 -
packages/ui/src/browser-setup.ts | 1 +
.../src/components/aurora-background.test.tsx | 7 +-
.../ui/src/components/aurora-background.tsx | 2 +-
packages/ui/src/components/auth-card.test.tsx | 27 +-
.../ui/src/components/auth-shell.test.tsx | 14 +-
packages/ui/src/components/button.test.tsx | 54 +--
.../ui/src/components/chat-message.test.tsx | 45 +-
.../src/components/check-state-icon.test.tsx | 12 +-
.../composer-timeline-badge.test.tsx | 16 +-
.../components/conversation-timeline.test.tsx | 23 +-
.../ui/src/components/count-heading.test.tsx | 17 +-
.../ui/src/components/cover-frame.test.tsx | 21 +-
.../ui/src/components/dashed-empty.test.tsx | 19 +-
.../ui/src/components/data-table.test.tsx | 51 +--
.../ui/src/components/date-picker.test.tsx | 53 +--
.../src/components/day-range-picker.test.tsx | 72 ++--
.../components/day-time-range-picker.test.tsx | 65 ++-
.../ui/src/components/decision-card.test.tsx | 19 +-
.../ui/src/components/detail-section.test.tsx | 17 +-
.../ui/src/components/error-well.test.tsx | 13 +-
.../ui/src/components/file-dropzone.test.tsx | 33 +-
.../ui/src/components/form-dialog.test.tsx | 71 ++-
.../ui/src/components/form-feedback.test.tsx | 47 +-
.../ui/src/components/form-field.test.tsx | 40 +-
.../ui/src/components/gear-popover.test.tsx | 21 +-
.../ui/src/components/icon-badge.test.tsx | 13 +-
.../ui/src/components/inline-notice.test.tsx | 17 +-
packages/ui/src/components/input.test.tsx | 33 +-
packages/ui/src/components/lift-card.test.tsx | 19 +-
.../components/list-filter-toolbar.test.tsx | 44 +-
.../components/loading-placeholder.test.tsx | 37 +-
packages/ui/src/components/menu-row.test.tsx | 19 +-
.../src/components/message-composer.test.tsx | 45 +-
.../ui/src/components/message-composer.tsx | 4 +-
.../ui/src/components/metric-tiles.test.tsx | 21 +-
.../ui/src/components/muted-copy.test.tsx | 13 +-
.../src/components/next-step-banner.test.tsx | 19 +-
.../ui/src/components/page-crumb-nav.test.tsx | 59 +--
.../ui/src/components/page-loading.test.tsx | 21 +-
.../src/components/panel-not-found.test.tsx | 15 +-
.../ui/src/components/removable-chip.test.tsx | 15 +-
.../ui/src/components/search-input.test.tsx | 35 +-
.../ui/src/components/section-card.test.tsx | 17 +-
.../ui/src/components/section-header.test.tsx | 29 +-
.../ui/src/components/select-row.test.tsx | 17 +-
.../ui/src/components/settings-aside.test.tsx | 19 +-
.../src/components/settings-footer.test.tsx | 15 +-
.../settings-panel-heading.test.tsx | 17 +-
.../ui/src/components/settings-panel.test.tsx | 13 +-
.../components/settings-split-layout.test.tsx | 15 +-
.../ui/src/components/settings-stack.test.tsx | 15 +-
.../ui/src/components/shell/client.test.tsx | 197 ++++-----
.../shell/components/two-columns.test.tsx | 77 ++--
.../sidebar-collapsible-item.test.tsx | 64 +--
.../sidebar-nested-load-more.test.tsx | 44 +-
.../ui/src/components/stacked-avatar.test.tsx | 21 +-
.../ui/src/components/stat-strip.test.tsx | 15 +-
.../ui/src/components/status-page.test.tsx | 21 +-
.../ui/src/components/suggest-row.test.tsx | 15 +-
packages/ui/src/components/tabs.test.tsx | 21 +-
packages/ui/src/components/textarea.test.tsx | 27 +-
packages/ui/src/components/theme-provider.tsx | 2 +
.../ui/src/components/theme-selector.test.tsx | 19 +-
.../timeline/timeline-badge.test.tsx | 18 +-
.../timeline/timeline-break.test.tsx | 14 +-
.../timeline/timeline-card.test.tsx | 21 +-
.../timeline/timeline-container.test.tsx | 22 +-
.../timeline/timeline-item.test.tsx | 30 +-
.../components/voice-record-button.test.tsx | 32 +-
.../src/components/workspace-notice.test.tsx | 21 +-
.../ui/src/hooks/use-voice-recorder.test.ts | 8 +-
.../src/lib/stub-popover-environment.test.ts | 16 -
.../ui/src/lib/stub-popover-environment.ts | 24 --
packages/ui/vite.config.ts | 12 +-
tooling/app-actions/README.md | 16 +
tooling/app-actions/package.json | 6 +-
.../src/backend/backend-app-actions.ts | 11 +
tooling/app-actions/src/backend/find-idea.ts | 19 +
.../app-actions/src/backend/invitations.ts | 24 ++
.../app-actions/src/backend/join-workspace.ts | 13 +-
.../src/playwright/accept-group-invitation.ts | 4 +-
.../src/playwright/account-security.ts | 4 +-
.../src/playwright/actions/add-activity.ts | 2 +-
.../actions/add-destination-to-idea.ts | 2 +-
.../src/playwright/actions/add-idea.ts | 2 +-
.../playwright/actions/add-issue-comment.ts | 2 +-
.../src/playwright/actions/apply-idea.ts | 2 +-
.../src/playwright/actions/approve-idea.ts | 2 +-
.../src/playwright/actions/archive-trip.ts | 2 +-
.../src/playwright/actions/assign-issue.ts | 2 +-
.../src/playwright/actions/create-issue.ts | 2 +-
.../src/playwright/actions/create-trip.ts | 2 +-
.../playwright/actions/request-idea-review.ts | 2 +-
.../src/playwright/actions/restore-trip.ts | 2 +-
.../src/playwright/actions/send-message.ts | 2 +-
.../playwright/actions/set-issue-status.ts | 2 +-
.../src/playwright/actions/update-trip.ts | 2 +-
.../src/playwright/add-first-destination.ts | 2 +-
.../app-actions/src/playwright/add-stop.ts | 2 +-
.../src/playwright/cancel-activity-edit.ts | 2 +-
.../app-actions/src/playwright/close-idea.ts | 2 +-
.../src/playwright/complete-onboarding.ts | 2 +-
.../src/playwright/create-first-group.ts | 2 +-
.../src/playwright/create-group-chat.ts | 2 +-
.../src/playwright/create-group.ts | 2 +-
.../src/playwright/create-trip-button.ts | 2 +-
.../src/playwright/delete-current-group.ts | 2 +-
.../src/playwright/edit-activity.ts | 2 +-
.../src/playwright/expect-home-dashboard.ts | 2 +-
.../src/playwright/expect-issue-status.ts | 2 +-
.../src/playwright/expect-trips-ready.ts | 2 +-
.../src/playwright/expect-workspace-ready.ts | 2 +-
.../src/playwright/filter-issues-by-status.ts | 2 +-
.../src/playwright/filter-trips-by-status.ts | 2 +-
tooling/app-actions/src/playwright/index.ts | 6 +-
.../src/playwright/inline-activity.ts | 2 +-
.../src/playwright/inspect-idea-changes.ts | 2 +-
.../src/playwright/invite-group-member.ts | 2 +-
.../app-actions/src/playwright/locators.ts | 2 +-
.../src/playwright/mock-destination-search.ts | 27 +-
.../app-actions/src/playwright/navigation.ts | 2 +-
.../playwright/open-background-activity.ts | 2 +-
.../src/playwright/open-chat-inbox.ts | 2 +-
.../src/playwright/open-idea-comparison.ts | 2 +-
.../src/playwright/open-idea-from-list.ts | 2 +-
.../src/playwright/open-idea-itinerary.ts | 2 +-
.../app-actions/src/playwright/open-idea.ts | 2 +-
.../src/playwright/open-issue-from-inbox.ts | 2 +-
.../src/playwright/open-issue-from-trip.ts | 2 +-
.../app-actions/src/playwright/open-issue.ts | 2 +-
.../src/playwright/open-issues-inbox.ts | 2 +-
.../src/playwright/open-mobile-chat.ts | 2 +-
.../src/playwright/open-shared-trip.ts | 2 +-
.../src/playwright/open-trip-from-list.ts | 2 +-
.../src/playwright/open-trip-section.ts | 2 +-
.../app-actions/src/playwright/open-trip.ts | 2 +-
.../app-actions/src/playwright/open-trips.ts | 2 +-
.../src/playwright/open-workspace-ideas.ts | 2 +-
.../src/playwright/plan-idea-activity.ts | 2 +-
.../src/playwright/react-to-chat-message.ts | 2 +-
.../src/playwright/recover-from-not-found.ts | 2 +-
.../redeem-group-invitation-code.ts | 2 +-
.../resolve-trip-detail-conflict.ts | 2 +-
.../src/playwright/review-idea-change.ts | 2 +-
.../src/playwright/save-seven-day-range.ts | 2 +-
.../app-actions/src/playwright/save-travel.ts | 2 +-
.../src/playwright/select-system-theme.ts | 2 +-
.../src/playwright/send-chat-message.ts | 2 +-
.../src/playwright/set-activity-day-range.ts | 2 +-
.../playwright/set-destination-schedule.ts | 2 +-
.../src/playwright/set-trip-dates.ts | 2 +-
.../src/playwright/show-shared-itinerary.ts | 2 +-
.../app-actions/src/playwright/sign-in-as.ts | 9 +-
.../app-actions/src/playwright/sign-out.ts | 2 +-
tooling/app-actions/src/playwright/sign-up.ts | 4 +-
.../src/playwright/start-idea-from-issue.ts | 2 +-
.../src/playwright/start-workspace-idea.ts | 2 +-
.../src/playwright/switch-group.ts | 2 +-
tooling/app-actions/src/playwright/test.ts | 95 ----
.../src/playwright/wait-for-review.ts | 2 +-
.../src/playwright/watch-shared-trip.ts | 2 +-
.../src/{playwright => testing}/ids.ts | 1 +
tooling/app-actions/src/testing/photon.ts | 29 ++
.../unique-suffix.test.ts | 0
.../{playwright => testing}/unique-suffix.ts | 0
.../unique-test-user.ts | 0
.../{playwright => testing}/worker-account.ts | 17 +-
.../src/vitest/account-security.ts | 135 ++++++
tooling/app-actions/src/vitest/app-host.ts | 29 ++
tooling/app-actions/src/vitest/arrange.ts | 19 +
tooling/app-actions/src/vitest/assistant.ts | 11 +
tooling/app-actions/src/vitest/auth.ts | 64 +++
tooling/app-actions/src/vitest/chat.ts | 51 +++
tooling/app-actions/src/vitest/commands.ts | 73 ++++
tooling/app-actions/src/vitest/create-trip.ts | 24 ++
tooling/app-actions/src/vitest/environment.ts | 25 ++
tooling/app-actions/src/vitest/group.ts | 79 ++++
tooling/app-actions/src/vitest/ideas.ts | 196 +++++++++
tooling/app-actions/src/vitest/index.ts | 169 ++++++++
tooling/app-actions/src/vitest/issues.ts | 108 +++++
tooling/app-actions/src/vitest/itinerary.ts | 324 ++++++++++++++
.../app-actions/src/vitest/journey-backend.ts | 94 ++++
tooling/app-actions/src/vitest/layout.ts | 93 ++++
tooling/app-actions/src/vitest/locators.ts | 85 ++++
tooling/app-actions/src/vitest/open-trips.ts | 59 +++
tooling/app-actions/src/vitest/queries.ts | 74 ++++
.../app-actions/src/vitest/search-trips.ts | 14 +
tooling/app-actions/src/vitest/settings.ts | 19 +
tooling/app-actions/src/vitest/trips.ts | 64 +++
tooling/app-actions/src/vitest/workspace.ts | 49 +++
tooling/app-actions/tsconfig.json | 8 +-
tooling/fallow.config.jsonc | 17 +-
tooling/quality/check-conventions.ts | 4 +-
tooling/vitest-config/package.json | 15 +-
.../vitest-config/src/console-guard/setup.ts | 1 +
.../vitest-config/src/console-guard/state.ts | 24 +-
tooling/vitest-config/src/react.ts | 97 +++++
tooling/vitest-config/src/web.test.ts | 30 ++
tooling/vitest-config/src/web.ts | 61 ++-
tooling/vitest-config/tsconfig.json | 1 +
491 files changed, 10135 insertions(+), 6868 deletions(-)
create mode 100644 .github/actions/setup-chromium/action.yml
delete mode 100644 apps/web/e2e/assistant/open-assistant.spec.ts
create mode 100644 apps/web/e2e/assistant/open-assistant.test.tsx
delete mode 100644 apps/web/e2e/auth/account-security.spec.ts
create mode 100644 apps/web/e2e/auth/account-security.test.tsx
delete mode 100644 apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts
create mode 100644 apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx
delete mode 100644 apps/web/e2e/chat/chat-reaction.spec.ts
create mode 100644 apps/web/e2e/chat/chat-reaction.test.tsx
delete mode 100644 apps/web/e2e/chat/chat-with-the-group.spec.ts
create mode 100644 apps/web/e2e/chat/chat-with-the-group.test.tsx
delete mode 100644 apps/web/e2e/group/invite-a-member.spec.ts
create mode 100644 apps/web/e2e/group/invite-a-member.test.tsx
delete mode 100644 apps/web/e2e/group/switch-groups.spec.ts
create mode 100644 apps/web/e2e/group/switch-groups.test.tsx
delete mode 100644 apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
create mode 100644 apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx
delete mode 100644 apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
delete mode 100644 apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
create mode 100644 apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx
delete mode 100644 apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
delete mode 100644 apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
delete mode 100644 apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
create mode 100644 apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx
delete mode 100644 apps/web/e2e/ideas/planning/destination-details-locator.spec.ts
delete mode 100644 apps/web/e2e/ideas/planning/idea-layout.spec.ts
delete mode 100644 apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
create mode 100644 apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx
delete mode 100644 apps/web/e2e/ideas/planning/start-an-idea.spec.ts
create mode 100644 apps/web/e2e/ideas/planning/start-an-idea.test.tsx
delete mode 100644 apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
create mode 100644 apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx
delete mode 100644 apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
create mode 100644 apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx
delete mode 100644 apps/web/e2e/ideas/review/close-idea.spec.ts
create mode 100644 apps/web/e2e/ideas/review/close-idea.test.tsx
rename apps/web/e2e/ideas/review/{idea-apply-gate.spec.ts => idea-apply-gate.test.tsx} (51%)
delete mode 100644 apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
rename apps/web/e2e/ideas/review/{review-idea-changes.spec.ts => review-idea-changes.test.tsx} (50%)
delete mode 100644 apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
create mode 100644 apps/web/e2e/ideas/review/review-idea-permissions.test.tsx
delete mode 100644 apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
delete mode 100644 apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
create mode 100644 apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx
delete mode 100644 apps/web/e2e/issues/create-an-issue.spec.ts
create mode 100644 apps/web/e2e/issues/create-an-issue.test.tsx
delete mode 100644 apps/web/e2e/issues/issue-comment-persistence.spec.ts
create mode 100644 apps/web/e2e/issues/issue-comment-persistence.test.tsx
delete mode 100644 apps/web/e2e/issues/reopen-issue.spec.ts
create mode 100644 apps/web/e2e/issues/reopen-issue.test.tsx
delete mode 100644 apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts
create mode 100644 apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx
delete mode 100644 apps/web/e2e/trips/archive-trip-filter.spec.ts
create mode 100644 apps/web/e2e/trips/archive-trip-filter.test.tsx
delete mode 100644 apps/web/e2e/trips/create-a-trip.spec.ts
create mode 100644 apps/web/e2e/trips/create-a-trip.test.tsx
delete mode 100644 apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
create mode 100644 apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx
delete mode 100644 apps/web/e2e/trips/trip-search.spec.ts
create mode 100644 apps/web/e2e/trips/trip-search.test.tsx
delete mode 100644 apps/web/e2e/workspace/browse-workspace.spec.ts
create mode 100644 apps/web/e2e/workspace/browse-workspace.test.tsx
rename apps/web/e2e/workspace/{workspace-idea-search.spec.ts => workspace-idea-search.test.tsx} (51%)
rename apps/web/e2e/workspace/{workspace-issue-search.spec.ts => workspace-issue-search.test.tsx} (50%)
delete mode 100644 apps/web/playwright.config.ts
create mode 100644 apps/web/src/app-root.tsx
create mode 100644 apps/web/src/browser-setup.ts
create mode 100644 apps/web/src/features/settings/settings-panels/appearance-settings.test.tsx
create mode 100644 apps/web/src/features/trips/itinerary-editor/itinerary-editor.test.tsx
create mode 100644 apps/web/src/features/trips/trip-detail/trip-page-shell.test.tsx
create mode 100644 apps/web/src/features/trips/trip-versions/decision-actions.test.tsx
create mode 100644 apps/web/src/features/workspace/workspace-shell/workspace-shell.test.tsx
delete mode 100644 apps/web/src/lib/test-mocks/dropdown-menu.ts
create mode 100644 apps/web/src/testing/itinerary-editor-fixture.tsx
create mode 100644 apps/web/src/testing/open-app.tsx
delete mode 100644 apps/web/src/testing/stub-match-media.ts
create mode 100644 apps/web/src/views-setup.ts
create mode 100644 packages/ai/src/browser-setup.css
create mode 100644 packages/ai/src/browser-setup.ts
rename packages/env/src/apps/{playwright.ts => journeys.ts} (72%)
create mode 100644 packages/ui/src/browser-setup.ts
delete mode 100644 packages/ui/src/lib/stub-popover-environment.test.ts
delete mode 100644 packages/ui/src/lib/stub-popover-environment.ts
create mode 100644 tooling/app-actions/src/backend/find-idea.ts
create mode 100644 tooling/app-actions/src/backend/invitations.ts
delete mode 100644 tooling/app-actions/src/playwright/test.ts
rename tooling/app-actions/src/{playwright => testing}/ids.ts (99%)
create mode 100644 tooling/app-actions/src/testing/photon.ts
rename tooling/app-actions/src/{playwright => testing}/unique-suffix.test.ts (100%)
rename tooling/app-actions/src/{playwright => testing}/unique-suffix.ts (100%)
rename tooling/app-actions/src/{playwright => testing}/unique-test-user.ts (100%)
rename tooling/app-actions/src/{playwright => testing}/worker-account.ts (68%)
create mode 100644 tooling/app-actions/src/vitest/account-security.ts
create mode 100644 tooling/app-actions/src/vitest/app-host.ts
create mode 100644 tooling/app-actions/src/vitest/arrange.ts
create mode 100644 tooling/app-actions/src/vitest/assistant.ts
create mode 100644 tooling/app-actions/src/vitest/auth.ts
create mode 100644 tooling/app-actions/src/vitest/chat.ts
create mode 100644 tooling/app-actions/src/vitest/commands.ts
create mode 100644 tooling/app-actions/src/vitest/create-trip.ts
create mode 100644 tooling/app-actions/src/vitest/environment.ts
create mode 100644 tooling/app-actions/src/vitest/group.ts
create mode 100644 tooling/app-actions/src/vitest/ideas.ts
create mode 100644 tooling/app-actions/src/vitest/index.ts
create mode 100644 tooling/app-actions/src/vitest/issues.ts
create mode 100644 tooling/app-actions/src/vitest/itinerary.ts
create mode 100644 tooling/app-actions/src/vitest/journey-backend.ts
create mode 100644 tooling/app-actions/src/vitest/layout.ts
create mode 100644 tooling/app-actions/src/vitest/locators.ts
create mode 100644 tooling/app-actions/src/vitest/open-trips.ts
create mode 100644 tooling/app-actions/src/vitest/queries.ts
create mode 100644 tooling/app-actions/src/vitest/search-trips.ts
create mode 100644 tooling/app-actions/src/vitest/settings.ts
create mode 100644 tooling/app-actions/src/vitest/trips.ts
create mode 100644 tooling/app-actions/src/vitest/workspace.ts
create mode 100644 tooling/vitest-config/src/react.ts
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-env-variable/SKILL.md b/.claude/skills/add-env-variable/SKILL.md
index 008b4e77..b5115d6e 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`, `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL` |
+| `journeys` | browser journeys (`apps/web/e2e`) | `CI`, `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 b751d535..335ec2cd 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.
@@ -31,18 +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/`.
-5. Specs import `test`/`expect` from `@groam/app-actions/playwright/test`:
- `page` is already signed in as the worker's own account and group, and
- the `arrange` fixture creates trips, ideas, issues, and members through
- the backend. Drive only the journey under test through the UI; second
- users get a page from `newSignedOutPage()`.
+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 575ddc6c..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
@@ -86,5 +105,6 @@ Reuse existing mocks before writing new ones: `@/testing/stub-match-media`,
## 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/post-create.sh b/.devcontainer/post-create.sh
index 5dcbc316..ab4bfa62 100644
--- a/.devcontainer/post-create.sh
+++ b/.devcontainer/post-create.sh
@@ -15,9 +15,9 @@ sudo chown -R "$(id -u):$(id -g)" "${persistent_dirs[@]}"
VP_GIT_HOOKS=0 vp install --frozen-lockfile
-# Browser tests need the Chromium build matching the locked Playwright version;
-# its system libraries come from the Dockerfile.
-vp run test:e2e:install
+# 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
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/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml
index 6f6f1c73..070d8912 100644
--- a/.github/workflows/test-reusable.yml
+++ b/.github/workflows/test-reusable.yml
@@ -44,19 +44,19 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: 📐 Format
+ - name: Format
run: bun run format:check
- - name: 🔍 Lint
+ - name: Lint
run: bun run lint
- - name: 🛡️ Audit dependencies
+ - name: Audit dependencies
run: bun run audit
- - name: 🩺 React Doctor
+ - name: React Doctor
run: bun run react-doctor
- - name: 🪦 Dead code
+ - name: Dead code
run: bun run fallow
typecheck:
@@ -74,7 +74,7 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: 🔷 Typecheck
+ - name: Typecheck
run: bun run typecheck
unit:
@@ -92,19 +92,22 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: 🧪 Unit tests
+ - name: Setup Chromium
+ uses: ./.github/actions/setup-chromium
+ - name: Unit tests
run: bun run test
- browser:
- name: 🎭 Browser
+ - name: Showcase recording
+ run: bun run --cwd tooling/showcase test:browser
+
+ journeys:
+ name: 🎭 Journeys
runs-on: ubuntu-latest
timeout-minutes: 60
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
+ STATIC_SITE_URL: http://localhost:3211
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -114,19 +117,8 @@ jobs:
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- - name: Cache Playwright browsers
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- restore-keys: |
- playwright-${{ runner.os }}-
-
- - name: Install Playwright browser
- run: bun run test:e2e:install --with-deps
-
- - name: 🎬 Showcase recording
- run: bun run --cwd tooling/showcase test:browser
+ - name: Setup Chromium
+ uses: ./.github/actions/setup-chromium
- name: Start local Convex backend
shell: bash
@@ -140,9 +132,8 @@ jobs:
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/"
- - name: ⚙️ Verify generated code
+ - name: Verify generated code
run: |
bun run codegen
git diff --exit-code
@@ -150,18 +141,19 @@ jobs:
- name: Run pending migrations
run: bun run migrations:run
+ - name: Journeys
+ run: bun run test:journeys
+
+ # 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: 🎭 Browser tests
- run: bun run test:e2e
+ 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()
@@ -172,8 +164,7 @@ jobs:
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()
diff --git a/CLAUDE.md b/CLAUDE.md
index ca20b47a..3c7c2f07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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/web/e2e/assistant/open-assistant.spec.ts b/apps/web/e2e/assistant/open-assistant.spec.ts
deleted file mode 100644
index 658145b2..00000000
--- a/apps/web/e2e/assistant/open-assistant.spec.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { openGroamAssistant, openTrip, uniqueSuffix } from '@groam/app-actions/playwright';
-import { test } from '@groam/app-actions/playwright/test';
-
-test('opens Groam AI on a trip with a ready prompt', async ({ arrange, page }) => {
- // Each worker 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 openTrip(page, tripId);
- 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 5b1a8a1d..00000000
--- a/apps/web/e2e/chat/chat-reaction.spec.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {
- chatMessage,
- chatRow,
- createGroupChat,
- openApp,
- openChatInbox,
- reactToChatMessage,
- reloadAppPage,
- sendChatMessage,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('a chat reaction remains visible after a page reload', async ({ arrange, page }) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const member = await arrange.addMember('reaction-member');
- await openApp(page);
- 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 eece3789..00000000
--- a/apps/web/e2e/chat/chat-with-the-group.spec.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- chatRow,
- createGroupChat,
- openApp,
- openChatInbox,
- sendMessage,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('chats with a group member', async ({ arrange, page }) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const member = await arrange.addMember('chat-member');
- await openApp(page);
-
- 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..e0ecf21f
--- /dev/null
+++ b/apps/web/e2e/chat/chat-with-the-group.test.tsx
@@ -0,0 +1,21 @@
+import {
+ arrange,
+ chatRow,
+ createGroupChat,
+ openChatInbox,
+ sendMessage,
+ 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 sendMessage({ text: `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 3b86d6c8..00000000
--- a/apps/web/e2e/group/invite-a-member.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
- acceptGroupInvitation,
- addIdea,
- by,
- ids,
- inviteGroupMember,
- openApp,
- openTrip,
- openTripFromList,
- openTrips,
- redeemGroupInvitationCode,
- tripCard,
- uniqueSuffix,
- uniqueTestUser,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('copies and pastes an invitation code that joins a member to the group', async ({
- arrange,
- newSignedOutPage,
- page
-}) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const member = uniqueTestUser('trip-member');
- const tripName = `Shared trip ${suffix}`;
-
- await openTrip(page, await arrange.createTrip({ name: tripName }));
- const invitationCode = await inviteGroupMember(page);
-
- const memberPage = await newSignedOutPage();
- 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 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 openApp(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..515d6b7c
--- /dev/null
+++ b/apps/web/e2e/group/invite-a-member.test.tsx
@@ -0,0 +1,64 @@
+import {
+ acceptGroupInvitation,
+ addIdea,
+ arrange,
+ by,
+ environment,
+ 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 expect.element(page.getByRole('button', { name: 'Edit details' })).not.toBeInTheDocument();
+
+ 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 expect.element(page.getByText('Unable to join group')).not.toBeInTheDocument();
+});
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 915b98a2..00000000
--- a/apps/web/e2e/group/switch-groups.spec.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import {
- by,
- createGroup,
- createTrip,
- deleteCurrentGroup,
- ids,
- openApp,
- openTrips,
- switchGroup,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('group switching isolates reactive Convex data', async ({ account, page }) => {
- test.setTimeout(90_000);
- await openApp(page);
- const homeGroupName = account.workspace.organizationName;
- 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: homeGroupName });
- 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: homeGroupName });
-});
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..2bbf260e
--- /dev/null
+++ b/apps/web/e2e/group/switch-groups.test.tsx
@@ -0,0 +1,35 @@
+import {
+ arrange,
+ by,
+ createGroup,
+ createTripFromTrips,
+ deleteCurrentGroup,
+ 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 });
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', tripName);
+
+ await switchGroup({ name: homeGroupName });
+ await expect.element(tripCard(tripName)).not.toBeInTheDocument();
+
+ 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 3cabdafa..00000000
--- a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import {
- addActivity,
- addStop,
- applyIdea,
- approveIdea,
- mockDestinationSearch,
- openAppPath,
- openIdea,
- openIdeaFromList,
- openItinerary,
- openSharedTrip,
- requestIdeaReview,
- saveSevenDayRange,
- signInAs,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('an approved idea activity appears on the shared itinerary only after applying it', async ({
- arrange,
- newSignedOutPage,
- page
-}) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const reviewerAccount = 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 });
- const { tripId } = await openIdea(page, { proposalId: idea.proposalId, tripId: sharedTripId });
- 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 reviewer = await newSignedOutPage();
- await signInAs(reviewer, reviewerAccount);
- await openIdeaFromList(reviewer, ideaName);
- await approveIdea(reviewer);
-
- 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..673c4f46
--- /dev/null
+++ b/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx
@@ -0,0 +1,54 @@
+import {
+ addActivity,
+ addStop,
+ applyIdea,
+ arrange,
+ environment,
+ expectPath,
+ openAppPath,
+ openIdea,
+ openIdeaFromList,
+ openItinerary,
+ 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 openItinerary();
+ await addStop({ 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(ideaName);
+ 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(ideaName);
+ 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 7eb7e235..00000000
--- a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import {
- addActivity,
- addStop,
- discardActivityChanges,
- ids,
- inlineActivityEditor,
- keepActivityPlan,
- mockDestinationSearch,
- openActivityPlan,
- openIdea,
- openIdeaFromList,
- openIdeaItinerary,
- openItinerary,
- saveSevenDayRange,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('cancelling edits or removal leaves the saved idea activity intact', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Cancel edits ${suffix}` });
- const ideaName = `Keep the plan ${suffix}`;
- const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 4fca297e..00000000
--- a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import {
- addActivity,
- addStop,
- continueActivityPlan,
- editActivity,
- ids,
- inlineActivityEditor,
- mockDestinationSearch,
- openActivityPlan,
- openIdeaComparison,
- openIdea,
- openIdeaFromList,
- openIdeaItinerary,
- openIssue,
- openItinerary,
- openSharedTrip,
- openTripSection,
- removeActivityPlan,
- requestIdeaReview,
- returnIdeaToEditing,
- saveSevenDayRange,
- startActivityPlan,
- startIdeaFromIssue,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('adds, edits and removes activities on a later-day idea from both entry points', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const ideaName = `Later-day activities ${suffix}`;
- const tripId = await arrange.createTrip({ name: `Later-day trip ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: ideaName, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- 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 openIdea(page, { proposalId, tripId });
- 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Issue itinerary ${suffix}` });
- await openIssue(
- page,
- await arrange.createIssue({
- 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..769c429f
--- /dev/null
+++ b/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx
@@ -0,0 +1,89 @@
+import {
+ addActivity,
+ addStop,
+ arrange,
+ by,
+ continueActivityPlan,
+ editActivity,
+ environment,
+ ids,
+ openIdea,
+ openIdeaComparison,
+ openIdeaFromList,
+ openIdeaItinerary,
+ openIssue,
+ openItinerary,
+ 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 openItinerary();
+ await addStop({ 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(ideaName);
+ await openIdeaComparison();
+ await requestIdeaReview();
+ // Authors may still amend their working copy during review.
+ await openIdeaFromList(ideaName);
+ 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({ name: 'Lisbon', notes: 'Walk', startDay: '1', endDay: '3' });
+ await addActivity({ destination: 'Lisbon, Portugal', title: 'Walk through Alfama' });
+ await openSharedTrip();
+ await openTripSection('itinerary');
+ await expect.element(page.getByText('Walk through Alfama')).not.toBeInTheDocument();
+ await openTripSection('ideas');
+ await openIdeaFromList(ideaName);
+ 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 2559ced2..00000000
--- a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- addStop,
- continueActivityPlan,
- ids,
- inlineActivityEditor,
- mockDestinationSearch,
- openActivityPlan,
- openIdea,
- openItinerary,
- saveSevenDayRange,
- startActivityPlan,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('editing from a continuation day shows the complete activity range', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Multi-day plan ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: `Multi-day idea ${suffix}`, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 33155ea1..00000000
--- a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import {
- addInlineActivity,
- addStop,
- editInlineActivity,
- ids,
- mockDestinationSearch,
- openIdea,
- openItinerary,
- saveSevenDayRange,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('plans morning and afternoon activities directly within an idea day', async ({
- arrange,
- page
-}) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Inline itinerary ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: `Plan this day ${suffix}`, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(150_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Shared day ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: `Two places ${suffix}`, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Mobile day plan ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: `Mobile activities ${suffix}`, tripId });
- await openIdea(page, { proposalId, tripId });
- 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 ce0a51fd..00000000
--- a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {
- addActivity,
- addFirstDestination,
- editActivity,
- mockDestinationSearch,
- openIdea,
- openIdeaFromList,
- openIdeaItinerary,
- openItinerary,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('plans and revisits an activity before trip dates or destination days are set', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- 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 openIdea(page, { proposalId, tripId });
- 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..fbe41003
--- /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,
+ openIdeaItinerary,
+ openItinerary,
+ 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 openItinerary();
+ 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(ideaName);
+ 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 a61dd537..00000000
--- a/apps/web/e2e/ideas/planning/idea-layout.spec.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- by,
- ids,
- openApp,
- openIdeaComparison,
- openIdeaFromList,
- openTrip,
- openTripSection,
- openWorkspaceIdeas,
- pageCanvas,
- resolvedBackground,
- twoColumnPageLayout,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('idea tabs share the page canvas while itinerary uses its full width', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- const tripId = await arrange.createTrip({ name: `Layout defaults ${uniqueSuffix()}` });
- const ideaName = `Consistent tabs ${uniqueSuffix()}`;
- await arrange.startIdea({ name: ideaName, tripId });
- await openApp(page);
- 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 ({ arrange, page }) => {
- test.setTimeout(120_000);
- await openTrip(page, await arrange.createTrip({ 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 cf2bcd14..00000000
--- a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {
- addActivity,
- addStop,
- editActivity,
- mockDestinationSearch,
- openIdea,
- openItinerary,
- openTrips,
- saveSevenDayRange,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('plans a trip itinerary on an idea working copy', async ({ arrange, page }) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- 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 openIdea(page, { proposalId: idea.proposalId, tripId });
- await saveSevenDayRange(page);
-
- 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..b88b4575
--- /dev/null
+++ b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx
@@ -0,0 +1,53 @@
+import {
+ addActivity,
+ addStop,
+ arrange,
+ editActivity,
+ environment,
+ openIdea,
+ openItinerary,
+ 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 openItinerary();
+ await addStop({ endDay: '3', 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 c564eebe..00000000
--- a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- addIdea,
- by,
- ideaRow,
- ids,
- openIdeaFromList,
- openSharedTrip,
- openTrip,
- openTripSection,
- openWorkspaceIdeas,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('starts an idea and opens first-class idea routes', async ({ arrange, page }) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const tripName = `Idea routes ${suffix}`;
- const ideaName = `routes/open-idea-${suffix}`;
-
- const tripId = await openTrip(page, await arrange.createTrip({ 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..093bdbc5
--- /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(ideaName);
+ await expect.element(by(ids.ideaContinue, draftRow)).toBeVisible();
+ await presentAttribute(draftRow, 'data-idea-id');
+
+ await openWorkspaceIdeas();
+ await expect.element(ideaRow(ideaName)).toBeVisible();
+
+ await openIdeaFromList(ideaName);
+ 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 2bff8875..00000000
--- a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import {
- by,
- ids,
- openApp,
- openSharedTrip,
- startWorkspaceIdea,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('starts a new idea from workspace Ideas for the selected trip', async ({ arrange, page }) => {
- test.setTimeout(120_000);
- 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(page);
- const ideaName = `Change the selected trip ${suffix}`;
-
- await startWorkspaceIdea(page, { tripName: selectedName, title: ideaName });
- // The shared trip check below proves the idea belongs to the selected trip.
- await expect(page).toHaveURL(/\/trips\/[^/]+\/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 6c3e16bd..00000000
--- a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- addFirstDestination,
- addIdea,
- by,
- ids,
- mockDestinationSearch,
- openDestinationDetails,
- openTrip,
- openTripSection,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('starts an idea and adds the first destination through one composer', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- await mockDestinationSearch(page);
- await openTrip(page, await arrange.createTrip({ 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 e9cec088..00000000
--- a/apps/web/e2e/ideas/review/close-idea.spec.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- closeIdea,
- filterWorkspaceIdeas,
- ideaRow,
- ids,
- openIdea,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('deletes a draft idea without leaving an editable working copy', async ({ arrange, page }) => {
- test.setTimeout(120_000);
- 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 openIdea(page, { proposalId, tripId });
- 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 ({ arrange, page }) => {
- test.setTimeout(120_000);
- 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 openIdea(page, { proposalId: idea.proposalId, tripId });
- 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..ca371f95
--- /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(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(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.test.tsx
similarity index 51%
rename from apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
rename to apps/web/e2e/ideas/review/idea-apply-gate.test.tsx
index 919652e1..78b2ef6e 100644
--- a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts
+++ b/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx
@@ -1,28 +1,27 @@
import {
+ arrange,
by,
ids,
openIdea,
openIdeaComparison,
reloadAppPage,
uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
+} 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(90_000);
+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 openIdea(page, { proposalId: idea.proposalId, tripId });
- await openIdeaComparison(page);
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
+ await openIdeaComparison();
- 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();
+ 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 708809f3..00000000
--- a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import {
- by,
- ids,
- openIdea,
- openIdeaComparison,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('an empty idea cannot be submitted until it contains a change', async ({ arrange, page }) => {
- test.setTimeout(90_000);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Empty proposal ${suffix}` });
- const { proposalId } = await arrange.startIdea({ name: `Fill this idea ${suffix}`, tripId });
- await openIdea(page, { proposalId, tripId });
- 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.test.tsx
similarity index 50%
rename from apps/web/e2e/ideas/review/review-idea-changes.spec.ts
rename to apps/web/e2e/ideas/review/review-idea-changes.test.tsx
index dd5a8bc9..96d2e9e0 100644
--- a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts
+++ b/apps/web/e2e/ideas/review/review-idea-changes.test.tsx
@@ -1,28 +1,27 @@
import {
+ arrange,
by,
ids,
openIdea,
returnIdeaToEditing,
reviewIdeaChange,
uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
+} from '@groam/app-actions/vitest';
+import { expect, test } from 'vite-plus/test';
+import { openApp } from '@/testing/open-app';
-test('reviews an edit, comments without blocking it, and returns to editing on mobile', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
+// 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 openIdea(page, { proposalId: idea.proposalId, tripId });
+ await openApp();
+ await openIdea({ proposalId: idea.proposalId, tripId });
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);
+ 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 e5c243de..00000000
--- a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import {
- addActivity,
- addStop,
- approveIdea,
- ids,
- mockDestinationSearch,
- openAppPath,
- openIdea,
- openIdeaComparison,
- openIdeaFromList,
- openIdeaItinerary,
- openTripSection,
- requestIdeaReview,
- saveSevenDayRange,
- signInAs,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('reviewers can inspect and approve an idea but cannot edit its working copy', async ({
- arrange,
- newSignedOutPage,
- page
-}) => {
- test.setTimeout(180_000);
- await mockDestinationSearch(page);
- const suffix = uniqueSuffix();
- const reviewerAccount = 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 openIdea(page, { proposalId, tripId });
- 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 reviewer = await newSignedOutPage();
- await signInAs(reviewer, reviewerAccount);
- 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();
-});
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..b68cde8d
--- /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({ 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(ideaName);
+ 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(ideaName);
+ 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 27c49259..00000000
--- a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ids, openApp, openBackgroundActivity } from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('background activity is discoverable in settings and opens a real run', async ({ page }) => {
- await openApp(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 189dd808..00000000
--- a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import {
- approveIdea,
- by,
- ids,
- openIdea,
- openIdeaComparison,
- openIdeaFromList,
- revokeIdeaApproval,
- signInAs,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('revoking the only approval prevents an idea from being applied', async ({
- arrange,
- newSignedOutPage,
- page
-}) => {
- test.setTimeout(180_000);
- 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 reviewerAccount = await arrange.addMember('approval-reversal');
-
- const reviewer = await newSignedOutPage();
- await signInAs(reviewer, reviewerAccount);
- await openIdeaFromList(reviewer, ideaName);
- await approveIdea(reviewer);
- await openIdea(page, { proposalId: idea.proposalId, tripId });
- await openIdeaComparison(page);
- 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();
-});
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 ee81e9ee..00000000
--- a/apps/web/e2e/issues/create-an-issue.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
- by,
- createIssue,
- createPlaywrightActions,
- filterIssuesByStatus,
- ids,
- issueRow,
- openApp,
- openAppPath,
- openIssueFromInbox,
- openIssuesInbox,
- openTrip,
- openTripSection,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('creates a trip issue and opens it as a first-class page', async ({ arrange, page }) => {
- test.setTimeout(90_000);
- 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 openTrip(page, await arrange.createTrip({ 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 openApp(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 91d50dcc..00000000
--- a/apps/web/e2e/issues/issue-comment-persistence.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import {
- by,
- createPlaywrightActions,
- ids,
- openIssue,
- openIssueFromInbox,
- openIssueFromTrip,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('issue comments persist across trip and workspace views', async ({ arrange, page }) => {
- test.setTimeout(90_000);
- 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 openIssue(page, issueId);
- 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 7177dc7b..00000000
--- a/apps/web/e2e/issues/reopen-issue.spec.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
- by,
- filterIssuesByStatus,
- ids,
- issueRow,
- openIssue,
- openIssueFromInbox,
- openIssuesInbox,
- setIssueStatus,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('reopening a closed issue moves it back into the open workspace filter', async ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
- 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 openIssue(page, issueId);
- 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 28a57faa..00000000
--- a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import {
- applyIdea,
- approveIdea,
- by,
- idFromPath,
- ids,
- openAppPath,
- openIdeaFromList,
- openIssue,
- requestIdeaReview,
- signInAs,
- startIdeaFromIssue,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('creates a trip, makes an issue, and resolves it with a merged idea', async ({
- arrange,
- newSignedOutPage,
- page
-}) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const member = await arrange.addMember('approver');
- const tripId = await arrange.createTrip({ name: `Merge trip ${suffix}` });
- const issueId = await openIssue(
- page,
- await arrange.createIssue({
- 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 memberPage = await newSignedOutPage();
- await signInAs(memberPage, member);
- await openIdeaFromList(memberPage, ideaName);
- await expect(memberPage).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+/u, { timeout: 20_000 });
- await approveIdea(memberPage);
-
- 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..ed8b44f4
--- /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(ideaName);
+ 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 64349926..00000000
--- a/apps/web/e2e/trips/archive-trip-filter.spec.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {
- archiveTrip,
- filterTripsByStatus,
- openAppPath,
- openTrip,
- openTripFromList,
- openTrips,
- restoreTrip,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('archived trips leave the active list and return after restoring', async ({
- arrange,
- page
-}) => {
- test.setTimeout(90_000);
- const tripName = `Archive filter ${uniqueSuffix()}`;
- await openTrip(page, await arrange.createTrip({ 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 28c59746..00000000
--- a/apps/web/e2e/trips/create-a-trip.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- archiveTrip,
- by,
- createTrip,
- ids,
- openApp,
- openTrips,
- restoreTrip,
- tripCard,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('creates, archives, restores, and lists a destination-optional trip', async ({ page }) => {
- test.setTimeout(90_000);
- await openApp(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..0d2b1a07
--- /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 });
+ 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 b1050c42..00000000
--- a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- addIdea,
- approveAndApplyIdea,
- by,
- ids,
- openAppPath,
- openIdea,
- openIdeaComparison,
- openTrip,
- openTripSection,
- reloadAppPage,
- requestIdeaReview,
- resolveTripDetailConflict,
- uniqueSuffix,
- updateTrip
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async ({
- arrange,
- page
-}) => {
- test.setTimeout(180_000);
- 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 openTrip(page, tripId);
- 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();
-
- 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(page, { proposalId: competing.proposalId, tripId });
- await openIdeaComparison(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..31d7cf44
--- /dev/null
+++ b/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx
@@ -0,0 +1,77 @@
+import {
+ addIdea,
+ approveAndApplyIdea,
+ arrange,
+ by,
+ currentPath,
+ 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.poll(() => by(ids.itineraryDetailsResolve).elements().length).toBe(0);
+ await expect
+ .poll(() => page.getByText('The shared trip has changed since you started').elements().length)
+ .toBe(0);
+ 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 expect.poll(() => by(ids.itineraryDetailsResolve).elements().length).toBe(0);
+ await reloadAppPage();
+ await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName);
+ await expect.poll(() => by(ids.itineraryDetailsResolve).elements().length).toBe(0);
+ await openIdeaComparison();
+ await expect
+ .poll(() => page.getByText('The shared trip has changed since you started').elements().length)
+ .toBe(0);
+ 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 15439ad7..00000000
--- a/apps/web/e2e/trips/trip-search.spec.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { openApp, searchTrips, tripCard, uniqueSuffix } from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('trip search only shows matching plans and clearing it restores the list', async ({
- arrange,
- page
-}) => {
- test.setTimeout(90_000);
- 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(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..56fec87b
--- /dev/null
+++ b/apps/web/e2e/trips/trip-search.test.tsx
@@ -0,0 +1,19 @@
+import { arrange, 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 expect.element(tripCard(second)).not.toBeInTheDocument();
+ 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 f4c49799..00000000
--- a/apps/web/e2e/workspace/browse-workspace.spec.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import {
- by,
- expectHomeDashboard,
- ids,
- openApp,
- openAppPath,
- openChatInbox,
- openFirstMobileListRow,
- openIssuesInbox,
- openMobileChat,
- openSettingsGroupSection,
- recoverFromNotFound,
- selectSystemTheme,
- uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
-
-test('group people live in settings, not the sidebar', async ({ page }) => {
- await openApp(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 openApp(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 openApp(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 ({
- arrange,
- page
-}) => {
- test.setTimeout(180_000);
- const suffix = uniqueSuffix();
- const tripId = await arrange.createTrip({ name: `Mobile lists ${suffix}` });
- await arrange.startIdea({ name: `Mobile idea ${suffix}`, tripId });
- await arrange.createIssue({
- 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..05dd2b73
--- /dev/null
+++ b/apps/web/e2e/workspace/browse-workspace.test.tsx
@@ -0,0 +1,44 @@
+import {
+ by,
+ expectHomeDashboard,
+ expectPath,
+ 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';
+
+// The system-theme/mobile-navigation and mobile list-row tests are layout checks of single
+// components and are left to view tests. Console errors are now caught for every journey.
+test('group people live in settings, not the sidebar', async () => {
+ await openApp();
+ await expect.poll(() => page.getByRole('link', { name: 'Group' }).elements().length).toBe(0);
+ 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.test.tsx
similarity index 51%
rename from apps/web/e2e/workspace/workspace-idea-search.spec.ts
rename to apps/web/e2e/workspace/workspace-idea-search.test.tsx
index 7d34ebf5..ddffbd2e 100644
--- a/apps/web/e2e/workspace/workspace-idea-search.spec.ts
+++ b/apps/web/e2e/workspace/workspace-idea-search.test.tsx
@@ -1,17 +1,15 @@
import {
+ arrange,
+ expectGone,
ideaRow,
- openApp,
openWorkspaceIdeas,
searchIdeas,
uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
+} 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
+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}`;
@@ -19,13 +17,13 @@ test('workspace idea search finds the matching proposal and clears cleanly', asy
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(page);
- await openWorkspaceIdeas(page);
+ await openApp();
+ await openWorkspaceIdeas();
- 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();
+ await searchIdeas('Museum proposal');
+ await expect.element(ideaRow(second)).toBeVisible();
+ await expectGone(ideaRow(first));
+ await searchIdeas('');
+ await expect.element(ideaRow(second)).toBeVisible();
+ await expect.element(ideaRow(first)).toBeVisible();
});
diff --git a/apps/web/e2e/workspace/workspace-issue-search.spec.ts b/apps/web/e2e/workspace/workspace-issue-search.test.tsx
similarity index 50%
rename from apps/web/e2e/workspace/workspace-issue-search.spec.ts
rename to apps/web/e2e/workspace/workspace-issue-search.test.tsx
index 43000f4b..04f73591 100644
--- a/apps/web/e2e/workspace/workspace-issue-search.spec.ts
+++ b/apps/web/e2e/workspace/workspace-issue-search.test.tsx
@@ -1,30 +1,28 @@
import {
+ arrange,
+ expectGone,
issueRow,
- openApp,
openIssuesInbox,
searchIssues,
uniqueSuffix
-} from '@groam/app-actions/playwright';
-import { expect, test } from '@groam/app-actions/playwright/test';
+} 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 ({
- arrange,
- page
-}) => {
- test.setTimeout(120_000);
+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(page);
- await openIssuesInbox(page);
+ await openApp();
+ await openIssuesInbox();
- 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();
+ 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..cb1c9daf 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",
"lint": "vp check .",
"preview": "vp preview --host 0.0.0.0",
"routes:generate": "NODE_ENV=production tsr generate",
- "test": "vp test run --coverage",
+ "journeys": "vp test run --project journeys",
+ "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 6b49daea..00000000
--- a/apps/web/playwright.config.ts
+++ /dev/null
@@ -1,21 +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 },
- // Every worker signs in as its own account and group (see @groam/app-actions/playwright/test),
- // so tests share no data and can run in any order.
- fullyParallel: true,
- outputDir: 'test-results',
- reporter: env.isCI ? [['github'], ['list'], ['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: env.isCI ? 2 : 4
-});
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/browser-setup.ts b/apps/web/src/browser-setup.ts
new file mode 100644
index 00000000..6a9a4b13
--- /dev/null
+++ b/apps/web/src/browser-setup.ts
@@ -0,0 +1 @@
+import './index.css';
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..dafc1738 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,6 @@
-import { cleanup, fireEvent, render, screen } 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 type { WorkspaceAgentRun } from '@/features/agents/hooks/use-workspace-agent-runs';
import { testIds } from '@/lib/test-ids';
import { AgentsListView } from './agents-list-view';
@@ -36,9 +37,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(
@@ -71,6 +75,8 @@ function run(
};
}
+afterEach(() => page.viewport(1280, 800));
+
beforeEach(() => {
vi.clearAllMocks();
state.isLoading = false;
@@ -79,24 +85,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 +143,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 +193,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 +213,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 +231,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 page.viewport(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 page.viewport(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/dashboard/dashboard-view.test.tsx b/apps/web/src/features/dashboard/dashboard-view.test.tsx
index 6ad9c928..53905b41 100644
--- a/apps/web/src/features/dashboard/dashboard-view.test.tsx
+++ b/apps/web/src/features/dashboard/dashboard-view.test.tsx
@@ -1,6 +1,7 @@
import { expectConsole } from '@groam/vitest-config/console';
-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 { 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,
@@ -11,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()
}));
@@ -24,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 () => {
@@ -73,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 () => {
@@ -112,14 +141,18 @@ 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 () => {
@@ -128,11 +161,15 @@ describe('DashboardView', () => {
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..8e1a6605 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,7 @@
-import { cleanup, fireEvent, render, screen, waitFor } 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 { 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 +18,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 +46,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 +66,86 @@ 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);
+afterEach(() => page.viewport(1280, 800));
-test('puts thread actions on the far right of the header', () => {
- render();
+test('puts thread actions on the far right of the header', async () => {
+ await page.viewport(390, 844);
+ await 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..c6a60d6d 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,23 @@
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, expect, test, vi } from 'vite-plus/test';
+import { afterEach, 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 { 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 +45,7 @@ const proposals: WorkspaceIdea[] = [
}
];
-function renderList() {
+async function renderList() {
return render(
@@ -60,17 +62,16 @@ function renderList() {
);
}
-test('renders a compact search-and-status toolbar instead of stacked filter chrome', () => {
- 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');
+test('renders a compact search-and-status toolbar instead of stacked filter chrome', async () => {
+ await renderList();
+
+ 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 +81,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 +124,44 @@ 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', () => {
+ afterEach(() => page.viewport(1280, 800));
+
+ 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 page.viewport(390, 844);
+ await renderList();
+ await expect.element(firstRow()).toBeVisible();
+
+ const cells = [...firstRow().element().querySelectorAll('td')];
+ await expect.poll(() => cells[0]?.getBoundingClientRect().width).toBeGreaterThan(200);
+ // Only the title and status stay; the trip name moves under the title.
+ expect(cells.filter((cell) => cell.checkVisibility())).toHaveLength(2);
+ await expect.element(firstRow().getByText('City break').first()).toBeVisible();
+ await expect.element(page.getByText('Started by')).not.toBeVisible();
+ await expect.element(page.getByRole('button', { name: 'Columns' })).not.toBeInTheDocument();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
+
+ 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();
+ const cells = [...firstRow().element().querySelectorAll('td')];
+ expect(cells.filter((cell) => cell.checkVisibility()).length).toBeGreaterThan(2);
+ 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..3a222948 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,22 @@
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, expect, test, vi } from 'vite-plus/test';
+import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser';
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 +40,7 @@ const issues: IssueListItem[] = [
}
];
-function renderList() {
+async function renderList() {
return render(
@@ -53,31 +55,67 @@ 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', () => {
+ afterEach(() => page.viewport(1280, 800));
+
+ 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 page.viewport(390, 844);
+ await renderList();
+ await expect.element(firstRow()).toBeVisible();
+
+ const cells = [...firstRow().element().querySelectorAll('td')];
+ await expect.poll(() => cells[0]?.getBoundingClientRect().width).toBeGreaterThan(200);
+ // Only the title and status stay.
+ expect(cells.filter((cell) => cell.checkVisibility())).toHaveLength(2);
+ await expect.element(page.getByText('Opened by')).not.toBeVisible();
+ await expect.element(page.getByRole('button', { name: 'Columns' })).not.toBeInTheDocument();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
- 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();
+ const cells = [...firstRow().element().querySelectorAll('td')];
+ expect(cells.filter((cell) => cell.checkVisibility()).length).toBeGreaterThan(2);
+ 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..5770587a 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,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 { ActiveOrganization } from '@/features/workspace/workspace-shell/workspace-state';
import { useOrganizationSettings } from './use-organization-settings';
@@ -47,8 +47,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 +73,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 +85,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 +98,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 +113,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 +132,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 +145,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 +155,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-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-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/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..f7a96edb
--- /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 { 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');
+ return page.viewport(1280, 800);
+});
+
+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 page.viewport(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..6e63d776
--- /dev/null
+++ b/apps/web/src/features/trips/itinerary-editor/itinerary-editor.test.tsx
@@ -0,0 +1,405 @@
+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 { testIds } from '@/lib/test-ids';
+import {
+ itineraryCalls,
+ itineraryTrip,
+ plannedActivity,
+ StatefulItineraryEditor
+} from '@/testing/itinerary-editor-fixture';
+
+vi.mock('@/features/media/hooks/use-media-upload', () => ({
+ useMediaUpload: () => vi.fn()
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => page.viewport(1280, 800));
+
+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 page.viewport(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..25f49f88 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, createTrip as fillCreateTrip } 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 fillCreateTrip(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 fillCreateTrip(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..d7f5f919
--- /dev/null
+++ b/apps/web/src/features/trips/trip-detail/trip-page-shell.test.tsx
@@ -0,0 +1,228 @@
+import Shell from '@groam/ui/components/shell/client';
+import { SidebarProvider } from '@groam/ui/components/sidebar';
+import { render } from '@groam/vitest-config/react';
+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 { itineraryTrip, StatefulItineraryEditor } from '@/testing/itinerary-editor-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(async () => {
+ document.documentElement.classList.remove('dark');
+ await page.viewport(1280, 800);
+});
+
+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 page.viewport(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 page.viewport(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 page.viewport(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 page.viewport(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({