diff --git a/.claude/agents/qa.md b/.claude/agents/qa.md index 3fd4180f..29abf32d 100644 --- a/.claude/agents/qa.md +++ b/.claude/agents/qa.md @@ -12,8 +12,8 @@ claims, including regression paths, loading/empty/error states, mobile layouts, and denied access where relevant. - Unit/integration: the narrowest `vp run test` scope that covers the slice. -- Browser: `just e2e-local` (`vp run test:e2e:local`) against Vite on - `http://localhost:5173`, not the Convex site proxy. It needs a running +- Browser: `just journeys` (`vp run test:journeys`) runs the browser-mode + journeys in `apps/web/e2e` against the local backend. It needs a running `just dev` stack; if none is running and you were not told you own one, report that instead of starting it. - Log in with the seeded demo owner `demo@groam.example` / `GroamDemo123!`. @@ -21,7 +21,7 @@ layouts, and denied access where relevant. - Adding coverage: read `add-web-view-test`, `add-convex-test`, or `add-test-id` first, and edit only test files the brief names. -Use `rg` to find existing specs under `apps/web/e2e/` and tests near the +Use `rg` to find existing journeys under `apps/web/e2e/` and tests near the changed code. No fabricated screenshots, skipped failures, or arbitrary sleeps. Return per criterion: pass/fail, reproducible steps, actual versus expected. diff --git a/.claude/skills/add-convex-test/SKILL.md b/.claude/skills/add-convex-test/SKILL.md index 17e00c24..21a40769 100644 --- a/.claude/skills/add-convex-test/SKILL.md +++ b/.claude/skills/add-convex-test/SKILL.md @@ -10,7 +10,7 @@ description: >- # Add a Convex test Colocate `.test.ts` next to the module under -`packages/backend/convex/modules/...` (Convex excludes `*.test.ts` from +`packages/backend/convex/modules//` (Convex excludes `*.test.ts` from deploys). Import test APIs from `vite-plus/test`, not `vitest`. ## Setup @@ -62,6 +62,10 @@ test('adds an activity to a scheduled destination', { timeout: 15_000 }, async ( - Tests go through `client.query` / `client.mutation` / `client.action`; `ctx.db` stays in modules and routes. - Use `{ timeout: 15_000 }` as the floor for authenticated fixtures. +- Scheduled functions wait on fake timers; drive them with + `finishAllScheduledFunctions(vi.runAllTimers)` or call `vi.useRealTimers()`. +- Console output fails the test unless declared with `expectConsole(level, + pattern)` from `@groam/vitest-config/console`. ## Run diff --git a/.claude/skills/add-env-variable/SKILL.md b/.claude/skills/add-env-variable/SKILL.md index 265794ea..fd70b7fb 100644 --- a/.claude/skills/add-env-variable/SKILL.md +++ b/.claude/skills/add-env-variable/SKILL.md @@ -29,7 +29,7 @@ scripts. | `runtime` | installed-app runtime | `GROAM_CONVEX_URL`, `GROAM_DATA_DIR` | | `desktop-tooling` | `apps/desktop/scripts` | `VITE_PHOTON_URL`, `CI` | | `seeder` | `tooling/seeder` | `SEED_CONVEX_URL`, `SEED_USER_EMAIL` | -| `playwright` | web E2E | `PLAYWRIGHT_BASE_URL`, seed credentials | +| `journeys` | browser journeys (`apps/web/e2e`) | `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL` | | `showcase` | `tooling/showcase` | `SHOWCASE_*` | | `vitest` | `tooling/vitest-config` | `CI` | diff --git a/.claude/skills/add-test-id/SKILL.md b/.claude/skills/add-test-id/SKILL.md index b878a8ae..29896710 100644 --- a/.claude/skills/add-test-id/SKILL.md +++ b/.claude/skills/add-test-id/SKILL.md @@ -1,7 +1,7 @@ --- name: add-test-id description: >- - Add a stable data-testid for Groam Playwright and Testing Library locators. + Add a stable data-testid for Groam browser-mode view tests and journeys. Use when a new control, dialog, row, or empty-state action needs a test hook — ids live in @groam/app-actions/ids, never as inline strings in one app. @@ -10,7 +10,7 @@ description: >- # Add a test id Stable locators are the `ids` object in -`tooling/app-actions/src/playwright/ids.ts`, published as +`tooling/app-actions/src/testing/ids.ts`, published as `@groam/app-actions/ids`. The web app re-exports it as `testIds` from `@/lib/test-ids`. @@ -31,13 +31,18 @@ Stable locators are the `ids` object in ``` -3. Query the same key in tests: `screen.getByTestId(testIds.agentStop)` in - Testing Library (`add-web-view-test`), `page.getByTestId(ids.agentStop)` - in Playwright. -4. For a repeated browser journey, add or reuse a helper in - `tooling/app-actions/src/playwright/.ts` (exported from its - `index.ts`) instead of repeating the steps in a spec under - `apps/web/e2e/`. +3. Query the same key in tests: `page.getByTestId(testIds.agentStop)` in + view tests (`add-web-view-test`) and journeys. +4. For a repeated browser step, add or reuse a helper in + `tooling/app-actions/src/vitest/.ts` (exported from its + `index.ts`) built on `page` locators from `vite-plus/test/browser`, + instead of repeating the steps in a test. +5. View tests and journeys use the same steps. Journeys + (`apps/web/e2e/**/*.test.tsx`) mount the whole app with `openApp(path)` + from `@/testing/open-app`, signed in as the file's own account and group; + `arrange` from `@groam/app-actions/vitest` creates trips, ideas, issues, + and members through the backend, and other people act through it too. + Drive only the journey under test through the UI. ## Rules diff --git a/.claude/skills/add-web-view-test/SKILL.md b/.claude/skills/add-web-view-test/SKILL.md index 52bd5d81..9ac0ecca 100644 --- a/.claude/skills/add-web-view-test/SKILL.md +++ b/.claude/skills/add-web-view-test/SKILL.md @@ -1,7 +1,7 @@ --- name: add-web-view-test description: >- - Add a Vitest + Testing Library test for a Groam web feature view. Use when + Add a Vite+ browser-mode test for a Groam web feature view. Use when covering a React screen, loading/empty/error state, dialog, or sidebar row in apps/web — mock feature hooks and agent context, never spin up Convex (use add-convex-test for backend behavior). @@ -10,15 +10,18 @@ description: >- # Add a web view test Colocate `.test.tsx` next to the component in -`apps/web/src/features//`. Mock the feature hooks, workspace state, -router, and agent context; render the view; assert roles, copy, and test -ids. +`apps/web/src/features//`. View tests run in real Chromium (the +`views` browser setup of `vp test`) with the app's styles. Mock the feature +hooks, workspace state, router, and agent context; render the view; drive it +with `page` locators; assert with `expect.element`, which retries. Testing +Library and jsdom are not used. ## Pattern (from `agents/agent-list/agents-list-view.test.tsx`) ```tsx -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import { testIds } from '@/lib/test-ids'; import { AgentsListView } from './agents-list-view'; @@ -51,23 +54,39 @@ beforeEach(() => { state.isLoading = false; state.runs = []; }); -afterEach(cleanup); describe('AgentsListView', () => { - test('shows a labeled loading state', () => { + test('shows a labeled loading state', async () => { state.isLoading = true; - render(); - expect(screen.getByRole('status', { name: 'Loading agent runs…' })).toBeTruthy(); + await render(); + await expect.element(page.getByRole('status', { name: 'Loading agent runs…' })).toBeVisible(); }); - test('shows the empty state', () => { - render(); - expect(screen.getByText('Nothing running yet')).toBeTruthy(); - expect(screen.queryByTestId(testIds.agentsRoster)).toBeNull(); + test('shows the empty state', async () => { + await render(); + await expect.element(page.getByText('Nothing running yet')).toBeVisible(); + await expect.element(page.getByTestId(testIds.agentsRoster)).not.toBeInTheDocument(); }); }); ``` +`render` and `renderHook` come from `@groam/vitest-config/react` and unmount +after each test. Interact like a user: `await page.getByLabelText(...).fill()`, +`.click()`. For many input combinations, drive the view through the shared +steps in `@groam/app-actions/vitest` (for example `createTrip(input)`) with +`test.each`, as `trip-create/create-trip-dialog.test.tsx` does. + +```tsx +test.each<[string, CreateTripFormInput]>([ + ['only a name', { name }], + ['a budget', { budget: 2500, name }] +])('creates a trip with %s', async (_, input) => { + await render(); + await fillCreateTrip(input); + await expect.poll(() => createTrip.mock.calls.length).toBe(1); +}); +``` + Reuse existing mocks before writing new ones: `@/testing/stub-match-media`, `@/testing/trip-planner-fixture`, `features/trips/test-workspace-mocks.tsx`, `features/agents/agent-test-router-mock.tsx`. Mock workspace state at @@ -80,8 +99,12 @@ Reuse existing mocks before writing new ones: `@/testing/stub-match-media`, `@groam/app-actions/ids` (`add-test-id`). - Hook logic gets its own `hooks/use-.test.ts(x)`; `packages/ui` primitives get a `*.test.tsx` beside the primitive. +- Console output fails the test unless declared with `expectConsole(level, + pattern)` from `@groam/vitest-config/console`; never silence it with + `vi.spyOn(console, …)`. ## Run -From `apps/web`: `vp test run src/features//.test.tsx`. -Whole repo: `just test`. +From `apps/web`: `vp test run --project views src/features//.test.tsx`. +Whole repo: `just test`. Full flows against the backend are journeys +(`apps/web/e2e`, `just journeys`); keep those few and cover combinations here. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index af5679aa..df93b8af 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -12,7 +12,8 @@ COPY --from=uv /uv /uvx /usr/local/bin/ COPY --from=just /just /usr/local/bin/ # Tauri 2 Linux prerequisites (https://tauri.app/start/prerequisites/) plus the -# extra packages from tauri-apps/tauri-action's GitHub workflow. +# extra packages from tauri-apps/tauri-action's GitHub workflow. libnss3 and +# libnspr4 are the only libraries Playwright's Chromium needs beyond them. RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ @@ -20,6 +21,8 @@ RUN apt-get update \ file \ libayatana-appindicator3-dev \ libgtk-3-dev \ + libnspr4 \ + libnss3 \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 032d93ca..ab4bfa62 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -15,6 +15,10 @@ sudo chown -R "$(id -u):$(id -g)" "${persistent_dirs[@]}" VP_GIT_HOOKS=0 vp install --frozen-lockfile +# Browser-mode tests (`vp test`) need the Chromium build matching the locked +# Playwright provider; its system libraries come from the Dockerfile. +vp run test:browsers:install + # Interactive `claude` in bash and zsh uses the repository harness (Headroom, # Caveman, MCP). An alias, not a PATH shim, so the harness still finds the real # binary. Home is rebuilt with the container, so this reruns on every rebuild; diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4a7a0db0..260baf5f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -56,20 +56,25 @@ just desktop ## Checks -Run what CI runs before opening a pull request: +Run the CI gate before opening a pull request: ```bash just check ``` +`just check` covers audit, typecheck, lint, unit tests, React Doctor, and +Fallow. CI also runs the browser journeys (`just journeys`, below) and the +showcase recording (`bun run --cwd tooling/showcase test:browser`), and builds +the desktop app on each platform; `just check` runs none of those. + Smaller loops: -| Command | What it does | -| ---------------- | ---------------------------------- | -| `just lint` | Conventions, syncpack, `vp check` | -| `just typecheck` | TypeScript | -| `just test` | Unit tests | -| `just e2e-local` | Playwright against Vite on `:5173` | +| Command | What it does | +| ---------------- | ----------------------------------------------------------------------------- | +| `just lint` | Conventions, Claude harness, syncpack, `vp check` | +| `just typecheck` | TypeScript | +| `just test` | Unit and view tests | +| `just journeys` | Browser journeys against the running local backend (start it with `just dev`) | ## Search the code graph @@ -85,8 +90,6 @@ just graphify-query "trip access planner" # matches identifiers, not prose Run `just graphify-refresh` after changing source files. The generated `graphify-out/` directory is intentionally local and ignored by Git. - - Graphify and its isolated Python environment are part of the container image; nothing is installed into the host or the Bun workspace. @@ -95,7 +98,7 @@ Do not hand-edit `apps/web/src/routeTree.gen.ts`. Add routes as files under ## Conventions -- Import through package aliases (`@groam/ui`, `@groam/ai-contracts`, `@/features/…`), +- Import through package aliases (`@groam/ui`, `@groam/app-actions`, `@/features/…`), never parent `../` paths across packages - Semantic color tokens only (no hex / rgb / Tailwind gradients in features) - Public Convex functions live under `packages/backend/convex/routes` as diff --git a/.github/actions/setup-chromium/action.yml b/.github/actions/setup-chromium/action.yml new file mode 100644 index 00000000..98bf2ae5 --- /dev/null +++ b/.github/actions/setup-chromium/action.yml @@ -0,0 +1,17 @@ +name: Setup Chromium +description: Install the Chromium build that Vite+ browser-mode tests drive, cached by lockfile + +runs: + using: composite + steps: + - name: Cache Chromium + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: chromium-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + chromium-${{ runner.os }}- + + - name: Install Chromium + shell: bash + run: bun run test:browsers:install --with-deps diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml index 7b24002e..79fd3829 100644 --- a/.github/actions/setup-workspace/action.yml +++ b/.github/actions/setup-workspace/action.yml @@ -15,21 +15,12 @@ runs: bun-version: '1.3.11' - name: Cache Bun downloads - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - - name: Cache Turbo - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: .turbo - key: turbo-${{ runner.os }}-${{ hashFiles('bun.lock') }}-${{ github.sha }} - restore-keys: | - turbo-${{ runner.os }}-${{ hashFiles('bun.lock') }}- - turbo-${{ runner.os }}- - - name: Install dependencies shell: bash env: diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 7d0c6b43..4a8394dc 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -14,12 +14,11 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: provision: name: Create Convex project and environments - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -55,7 +54,7 @@ jobs: deploy: name: Bring ${{ matrix.environment }} online needs: [provision, test] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 strategy: fail-fast: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 183eb9c8..4f74463b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,7 +20,7 @@ permissions: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/deploy-site-reusable.yml b/.github/workflows/deploy-site-reusable.yml index 258673ad..2945c0bf 100644 --- a/.github/workflows/deploy-site-reusable.yml +++ b/.github/workflows/deploy-site-reusable.yml @@ -22,12 +22,11 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: deploy: name: Deploy ${{ inputs.site }} to ${{ inputs.environment }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: ${{ inputs.environment }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6e3db56e..a33d9f38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,7 +23,6 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: test: @@ -33,7 +32,7 @@ jobs: backend: name: Deploy backend and migrations to ${{ inputs.environment }} needs: [test] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: ${{ inputs.environment }} @@ -98,7 +97,7 @@ jobs: smoke-test: name: Smoke test ${{ inputs.environment }} needs: [backend, sites] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: ${{ inputs.environment }} diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index e3691d5f..d9a910ec 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -23,12 +23,11 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: migrate: name: Run ${{ inputs.environment }} migrations - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 environment: name: ${{ inputs.environment }} diff --git a/.github/workflows/regenerate-showcase.yml b/.github/workflows/regenerate-showcase.yml index cb20e0f1..823bfa34 100644 --- a/.github/workflows/regenerate-showcase.yml +++ b/.github/workflows/regenerate-showcase.yml @@ -15,12 +15,11 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: regenerate: name: Regenerate showcase and open PR - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 240 steps: - name: Checkout default branch @@ -48,7 +47,6 @@ jobs: env: | CI CONVEX_AGENT_MODE - TURBO_TELEMETRY_DISABLED push: never runCmd: | set -Eeuo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46b8c099..797fbbad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ env: jobs: validate: name: Validate release request - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Validate source and signing key env: @@ -61,7 +61,7 @@ jobs: prepare: name: Prepare GitHub Release needs: [build] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: tag: ${{ steps.prep.outputs.tag }} steps: @@ -127,7 +127,7 @@ jobs: name: Create GitHub Release needs: [prepare] environment: release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Download verified release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 1bc10819..8fee35ec 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -13,19 +13,19 @@ env: GIT_CONFIG_COUNT: '1' GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main - TURBO_TELEMETRY_DISABLED: '1' jobs: - quality: - name: Quality - runs-on: ubuntu-latest - timeout-minutes: 60 + lint: + name: 🧹 Lint + runs-on: ubuntu-24.04 + timeout-minutes: 30 env: CI: 'true' steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # Fallow's hotspot analysis reads git history. fetch-depth: 0 persist-credentials: false @@ -44,27 +44,24 @@ jobs: - name: Setup workspace uses: ./.github/actions/setup-workspace - - name: Format check - run: bun run format:check + - name: Lint + run: bun run lint - - name: Lint, typecheck, and unit tests - run: bun run check + - name: Audit dependencies + run: bun run audit - e2e: - name: Browser (${{ matrix.shard }}/4) - needs: [quality] - runs-on: ubuntu-latest - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] + - name: React Doctor + run: bun run react-doctor + + - name: Dead code + run: bun run fallow + + typecheck: + name: 🔷 Typecheck + runs-on: ubuntu-24.04 + timeout-minutes: 30 env: CI: 'true' - CONVEX_AGENT_MODE: anonymous - # WebAuthn rejects IP addresses as relying-party domains. Keep the local - # static site on the same loopback service, but address it by hostname. - PLAYWRIGHT_BASE_URL: http://localhost:3211 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -74,20 +71,53 @@ jobs: - name: Setup workspace uses: ./.github/actions/setup-workspace - - name: Cache Playwright browsers - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Typecheck + run: bun run typecheck + + unit: + name: 🧪 Unit tests + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CI: 'true' + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} - restore-keys: | - playwright-${{ runner.os }}- + persist-credentials: false - - name: Install Playwright browser - run: bun run test:e2e:install --with-deps + - name: Setup workspace + uses: ./.github/actions/setup-workspace + + - name: Setup Chromium + uses: ./.github/actions/setup-chromium - - name: Test showcase recording + - name: Unit tests + run: bun run test + + - name: Showcase recording run: bun run --cwd tooling/showcase test:browser + journeys: + name: 🎭 Journeys + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CI: 'true' + CONVEX_AGENT_MODE: anonymous + STATIC_SITE_URL: http://localhost:3211 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup workspace + uses: ./.github/actions/setup-workspace + + - name: Setup Chromium + uses: ./.github/actions/setup-chromium + - name: Start local Convex backend shell: bash run: | @@ -99,8 +129,10 @@ jobs: sleep 2 done ' - bunx convex env set BETTER_AUTH_SECRET "ci-only-better-auth-secret-not-for-production" - bunx convex env set SITE_URL "$PLAYWRIGHT_BASE_URL/" + + # The same auth setup as local dev: a secret and the Vite site the passkey rpID comes from. + - name: Configure auth + run: bun tooling/devkit/configure-auth.ts - name: Verify generated code run: | @@ -110,33 +142,32 @@ jobs: - name: Run pending migrations run: bun run migrations:run + # A failing journey keeps its Playwright trace (network, console, DOM) in the failure artifact. + - name: Journeys + run: bun run test:journeys -- --browser.trace=retain-on-failure + + # The production build: published to the local static host and smoke-tested. - name: Publish local static sites run: bun run hosting:upload:web - name: Verify static routes shell: bash run: | - curl --fail --show-error --silent "$PLAYWRIGHT_BASE_URL/" >/dev/null - curl --fail --show-error --silent "$PLAYWRIGHT_BASE_URL/settings" >/dev/null - test "$(curl --output /dev/null --silent --write-out '%{http_code}' "$PLAYWRIGHT_BASE_URL/missing.js")" = "404" - - - name: Seed browser test data - run: bun run seed -- --users 1 --trips 0 - - - name: Run browser tests - run: bun run test:e2e -- --shard=${{ matrix.shard }}/4 + curl --fail --show-error --silent "$STATIC_SITE_URL/" >/dev/null + curl --fail --show-error --silent "$STATIC_SITE_URL/settings" >/dev/null + test "$(curl --output /dev/null --silent --write-out '%{http_code}' "$STATIC_SITE_URL/missing.js")" = "404" - name: Upload failure artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: test-failure-${{ github.run_id }}-${{ matrix.shard }} + name: test-failure-${{ github.run_id }} if-no-files-found: ignore + include-hidden-files: true retention-days: 7 path: | /tmp/convex.log - apps/web/playwright-report/ - apps/web/test-results/ + apps/web/.vitest/ - name: Stop local Convex backend if: always() @@ -147,8 +178,7 @@ jobs: fi desktop: - name: Desktop (${{ matrix.platform }}) - needs: [quality] + name: 🖥️ Desktop (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} timeout-minutes: 90 env: @@ -164,11 +194,11 @@ jobs: target: x86_64-unknown-linux-gnu bundles: deb - platform: macos-arm - runner: macos-latest + runner: macos-26 target: aarch64-apple-darwin bundles: app,dmg - platform: windows - runner: windows-latest + runner: windows-2025 target: x86_64-pc-windows-msvc bundles: nsis steps: @@ -185,6 +215,19 @@ jobs: with: targets: ${{ matrix.target }} + # Dependencies dominate the release build; keep the bundles themselves out of the cache. + - name: Cache Rust build + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + apps/desktop/src-tauri/target/ + !apps/desktop/src-tauri/target/*/release/bundle/ + key: rust-${{ matrix.target }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + restore-keys: rust-${{ matrix.target }}- + - name: Install Linux desktop dependencies if: matrix.platform == 'linux' shell: bash diff --git a/.github/workflows/update-convex-agent-files.yml b/.github/workflows/update-convex-agent-files.yml index 92d8fa98..88cfda29 100644 --- a/.github/workflows/update-convex-agent-files.yml +++ b/.github/workflows/update-convex-agent-files.yml @@ -16,7 +16,7 @@ permissions: jobs: update: name: Update files and open PR - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout default branch diff --git a/.gitignore b/.gitignore index bf6a61df..8c6598ab 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,10 @@ pnpm-debug.log* lerna-debug.log* node_modules -# Legacy Turbo caches in clones from before Vite+. -.turbo +*.tsbuildinfo .astro dist dist-ssr -playwright-report coverage **/test-results *.local diff --git a/CLAUDE.md b/CLAUDE.md index ca20b47a..f13a465c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,10 +105,10 @@ route, module, hook, env variable, test, or UI piece. under `tooling/`; root configs such as `vite.config.ts` only compose it. - **Enforced conventions:** `vp run lint:conventions` checks kebab-case file and folder names, package-alias imports instead of `../`, semantic color - tokens, `ctx.db` only in `modules/` and `routes/` (never in `'use node'` - files), `@groam/env/*` instead of raw env access, public `@groam/ui` - entrypoints, and thin route files. `vp run lint:claude` validates this - harness. + tokens, `ctx.db` only in `components/`, `modules/`, and `routes/` (never in + `'use node'` files), `@groam/env/*` instead of raw env access, public + `@groam/ui` entrypoints, and thin route files. `vp run lint:claude` + validates this harness. ## Local runtime @@ -118,9 +118,10 @@ route, module, hook, env variable, test, or UI piece. - Ports: web `5173`, Convex API `3210`, Convex site/HTTP `3211`, dashboard `6790`. `http://127.0.0.1:3211/` returns `503` locally because no static site is uploaded; that is expected. The app is served by Vite on `5173`. -- Browser e2e: `just e2e-local` against the running dev stack on - `http://localhost:5173` (must match `VITE_SITE_URL` for Better Auth). - `just e2e` targets the uploaded static site on `3211`, as CI does. +- Browser tests run in Chromium through Vite+ browser mode (`vp test`). View + tests are the `views` setup; `just journeys` runs the `journeys` setup + (`apps/web/e2e`): the real app against the running local backend, served on + `5174`, which local Better Auth trusts. - AI assistant features are optional and need an LLM provider key (see `packages/env`). diff --git a/Justfile b/Justfile index 58498ba8..dac2f94c 100644 --- a/Justfile +++ b/Justfile @@ -67,15 +67,10 @@ typecheck: test: vp run test -# Run browser tests against the CI-style static site. +# Run browser journeys against the running local backend (`just dev`). [group('quality')] -e2e: - vp run test:e2e - -# Run browser tests against Vite on localhost:5173. -[group('quality')] -e2e-local: - vp run test:e2e:local +journeys: + vp run test:journeys # Format manifests and source files. [group('quality')] diff --git a/README.md b/README.md index 1b35c370..8a777aca 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Open [localhost:5173](http://localhost:5173) and sign in with | `just check` | Run the complete CI quality suite | | `just test` | Run unit and integration tests | | `claude` | Start Claude Code with the repository harness (in the dev container) | -| `just e2e-local` | Run Playwright against the local app | +| `just journeys` | Run browser journeys against the local backend | | `just desktop` | Start desktop development | | `just graphify-explain ` | Show a symbol, its callers, and its dependencies | | `just graphify-affected ` | List what a change to a symbol would affect | diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore index 124b16f3..e0b8072c 100644 --- a/apps/desktop/src-tauri/.gitignore +++ b/apps/desktop/src-tauri/.gitignore @@ -5,5 +5,8 @@ /groam-runtime/ /binaries/ -# Written by tooling/desktop/release-version.ts during release builds +# Written by build.rs (tauri-build app manifest) for the custom commands +/permissions/autogenerated/ + +# Written by apps/desktop/scripts/updater-release.ts during release builds /desktop-version.txt diff --git a/apps/desktop/src-tauri/src/data_dir.rs b/apps/desktop/src-tauri/src/data_dir.rs index f872e786..98eab838 100644 --- a/apps/desktop/src-tauri/src/data_dir.rs +++ b/apps/desktop/src-tauri/src/data_dir.rs @@ -6,24 +6,6 @@ use std::{ pub(crate) const RUNTIME_BUILD_STAMP: &str = ".groam-runtime-build"; -pub(crate) fn copy_dir(source: &Path, destination: &Path) -> io::Result<()> { - fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - let target = destination.join(entry.file_name()); - let source_path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_symlink() { - copy_symlink(&source_path, &target)?; - } else if file_type.is_dir() { - copy_dir(&source_path, &target)?; - } else if file_type.is_file() { - fs::copy(&source_path, target)?; - } - } - Ok(()) -} - fn copy_backup_dir(source: &Path, destination: &Path) -> io::Result<()> { fs::create_dir_all(destination)?; for entry in fs::read_dir(source)? { @@ -45,18 +27,6 @@ fn copy_backup_dir(source: &Path, destination: &Path) -> io::Result<()> { Ok(()) } -fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> { - // Materialize links so a writable runtime copy does not depend on - // read-only packaged resources (workspace node_modules, etc.). - if source.is_dir() { - copy_dir(source, destination) - } else if source.is_file() { - fs::copy(source, destination).map(|_| ()) - } else { - Ok(()) - } -} - fn overlay_symlink(source: &Path, destination: &Path) -> io::Result<()> { if source.is_dir() { overlay_dir(source, destination) @@ -381,7 +351,7 @@ pub(crate) fn finish_failed_swap( #[cfg(test)] mod tests { use super::{ - copy_dir, finish_failed_swap, overlay_dir, recover_interrupted_data_dir, recover_pointer, + finish_failed_swap, overlay_dir, recover_interrupted_data_dir, recover_pointer, replace_dir, replace_dir_keep_previous, sync_writable_runtime, }; use std::{ @@ -653,34 +623,6 @@ mod tests { fs::remove_dir_all(&parent).ok(); } - #[test] - fn copy_dir_materializes_symlinks() { - let parent = temp_parent(); - let source = parent.join("staged"); - let dest = parent.join("writable"); - fs::create_dir_all(source.join("packages")).expect("packages"); - fs::write(source.join("packages/app.txt"), "from-package").expect("package file"); - fs::create_dir_all(source.join("node_modules")).expect("node_modules"); - std::os::unix::fs::symlink( - "../packages/app.txt", - source.join("node_modules/app.txt"), - ) - .expect("symlink"); - - copy_dir(&source, &dest).expect("copy"); - assert_eq!( - fs::read_to_string(dest.join("node_modules/app.txt")).expect("copied link"), - "from-package" - ); - assert!( - fs::symlink_metadata(dest.join("node_modules/app.txt")) - .expect("meta") - .file_type() - .is_file() - ); - fs::remove_dir_all(&parent).ok(); - } - #[test] fn sync_writable_runtime_copies_and_preserves_local_state() { let parent = temp_parent(); diff --git a/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md b/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md index c2eae38e..018baed9 100644 --- a/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md +++ b/apps/desktop/src-tauri/vendor/glib-0.18.5.GROAM.md @@ -7,5 +7,8 @@ This directory is the published `glib` 0.18.5 crate from - Security advisory: `GHSA-wrw7-89jp-8q8g` - Applied upstream fix: `gtk-rs/gtk-rs-core@44ff04449535135aa82507ff492883bba77a9b75` - Local delta: the commit's exact two-line `VariantStrIter::impl_get` mutability patch in `src/variant_iter.rs` +- Manifest delta: a `[lints.rust]` table allowing `mismatched_lifetime_syntaxes` and `unused_parens`. + A path patch is linted like our own code, so newer rustc lints on this 2023 source would + otherwise warn on every desktop build; registry crates get the same silence from `--cap-lints`. The upstream `LICENSE` and `COPYRIGHT` files are preserved in the crate directory. diff --git a/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml b/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml index 132e07df..78ba39a2 100644 --- a/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml +++ b/apps/desktop/src-tauri/vendor/glib-0.18.5/Cargo.toml @@ -183,3 +183,7 @@ v2_78 = [ "ffi/v2_78", "gobject_ffi/v2_78", ] + +[lints.rust] +mismatched_lifetime_syntaxes = "allow" +unused_parens = "allow" diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 7a6c0964..3ef610f1 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1 +1,2 @@ .vitest/ +__traces__/ diff --git a/apps/web/e2e/assistant/open-assistant.spec.ts b/apps/web/e2e/assistant/open-assistant.spec.ts deleted file mode 100644 index c1aa6214..00000000 --- a/apps/web/e2e/assistant/open-assistant.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - completeOnboarding, - createTrip, - openGroamAssistant, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { test } from '@playwright/test'; - -test('opens Groam AI on a trip with a ready prompt', async ({ page }) => { - test.setTimeout(120_000); - // A fresh user keeps the per-user AI thread rate limit out of the way. - await completeOnboarding(page, { groupName: `Assistant ${uniqueSuffix()}` }); - await createTrip(page, { name: `Assistant ${uniqueSuffix()}` }); - await openGroamAssistant(page); -}); diff --git a/apps/web/e2e/assistant/open-assistant.test.tsx b/apps/web/e2e/assistant/open-assistant.test.tsx new file mode 100644 index 00000000..7dd10f3e --- /dev/null +++ b/apps/web/e2e/assistant/open-assistant.test.tsx @@ -0,0 +1,18 @@ +import { + arrange, + assistantPanel, + openGroamAssistant, + openTrip, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('opens Groam AI on a trip with a ready prompt', async () => { + // Each journey file owns a fresh account, which keeps the per-user AI thread rate limit out of the way. + const tripId = await arrange.createTrip({ name: `Assistant ${uniqueSuffix()}` }); + await openApp(); + await openTrip(tripId); + await openGroamAssistant(); + await expect.element(assistantPanel()).toBeVisible(); +}); diff --git a/apps/web/e2e/auth/account-security.spec.ts b/apps/web/e2e/auth/account-security.spec.ts deleted file mode 100644 index 1f761f75..00000000 --- a/apps/web/e2e/auth/account-security.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - completeOnboarding, - enableTwoFactorAndSignInWithBackupCode, - openSettingsSecuritySection, - recoverAccountWithDownloadedCode, - registerPasskeyAndSignIn -} from '@groam/app-actions/playwright'; -import { test } from '@playwright/test'; - -const recoveredPassword = 'Recovered-password-1234'; -test.setTimeout(120_000); - -test('resets a forgotten password with a downloaded account recovery code', async ({ page }) => { - const { user } = await completeOnboarding(page, { groupName: `Recovery ${Date.now()}` }); - await openSettingsSecuritySection(page); - await recoverAccountWithDownloadedCode(page, user.username, recoveredPassword, user.password); -}); - -test('registers a passkey and signs in with the discoverable credential', async ({ page }) => { - await completeOnboarding(page, { groupName: `Passkey ${Date.now()}` }); - await openSettingsSecuritySection(page); - await registerPasskeyAndSignIn(page, 'Playwright authenticator'); -}); - -test('enables an authenticator and signs in with a downloaded backup code', async ({ page }) => { - const { user } = await completeOnboarding(page, { groupName: `Authenticator ${Date.now()}` }); - await openSettingsSecuritySection(page); - await enableTwoFactorAndSignInWithBackupCode(page, user); -}); diff --git a/apps/web/e2e/auth/account-security.test.tsx b/apps/web/e2e/auth/account-security.test.tsx new file mode 100644 index 00000000..cb710ead --- /dev/null +++ b/apps/web/e2e/auth/account-security.test.tsx @@ -0,0 +1,37 @@ +import { + by, + completeOnboarding, + ids, + enableTwoFactorAndSignInWithBackupCode, + openSettingsSecuritySection, + recoverAccountWithDownloadedCode, + registerPasskeyAndSignIn +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openSignedOutApp } from '@/testing/open-app'; + +const recoveredPassword = 'Recovered-password-1234'; + +test('resets a forgotten password with a downloaded account recovery code', async () => { + await openSignedOutApp(); + const { user } = await completeOnboarding({ groupName: `Recovery ${Date.now()}` }); + await openSettingsSecuritySection(); + await recoverAccountWithDownloadedCode(user.username, recoveredPassword, user.password); + await expect.element(by(ids.navTrips)).toBeVisible(); +}); + +test('registers a passkey and signs in with the discoverable credential', async () => { + await openSignedOutApp(); + await completeOnboarding({ groupName: `Passkey ${Date.now()}` }); + await openSettingsSecuritySection(); + await registerPasskeyAndSignIn('Virtual authenticator'); + await expect.element(by(ids.navTrips)).toBeVisible(); +}); + +test('enables an authenticator and signs in with a downloaded backup code', async () => { + await openSignedOutApp(); + const { user } = await completeOnboarding({ groupName: `Authenticator ${Date.now()}` }); + await openSettingsSecuritySection(); + await enableTwoFactorAndSignInWithBackupCode(user); + await expect.element(by(ids.navTrips)).toBeVisible(); +}); diff --git a/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts b/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts deleted file mode 100644 index 55604e9b..00000000 --- a/apps/web/e2e/auth/sign-up-and-create-a-group.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - by, - completeOnboarding, - createFirstGroup, - ids, - signUp, - uniqueTestUser -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('requires a typed group name before create group enables', async ({ page }) => { - const user = uniqueTestUser('onboarding'); - await signUp(page, user); - - const createGroupButton = by(page, ids.onboardingCreateGroup); - await expect(createGroupButton).toBeDisabled(); - await expect(by(page, ids.onboardingGroupName)).toHaveAttribute('placeholder', 'The Smiths'); - await createFirstGroup(page, `Typed Group ${Date.now()}`); - await expect(by(page, ids.navTrips)).toBeVisible(); -}); - -test('signs up and creates a first group', async ({ page }) => { - const { groupName } = await completeOnboarding(page, { - groupName: `Planning Crew ${Date.now()}` - }); - await expect(by(page, ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName); -}); diff --git a/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx b/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx new file mode 100644 index 00000000..73e9708a --- /dev/null +++ b/apps/web/e2e/auth/sign-up-and-create-a-group.test.tsx @@ -0,0 +1,26 @@ +import { + by, + completeOnboarding, + createFirstGroup, + ids, + signUp, + uniqueTestUser +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openSignedOutApp } from '@/testing/open-app'; + +test('requires a typed group name before create group enables', async () => { + await openSignedOutApp(); + await signUp(uniqueTestUser('onboarding')); + + await expect.element(by(ids.onboardingCreateGroup)).toBeDisabled(); + await expect.element(by(ids.onboardingGroupName)).toHaveAttribute('placeholder', 'The Smiths'); + await createFirstGroup(`Typed Group ${Date.now()}`); + await expect.element(by(ids.navTrips)).toBeVisible(); +}); + +test('signs up and creates a first group', async () => { + await openSignedOutApp(); + const { groupName } = await completeOnboarding({ groupName: `Planning Crew ${Date.now()}` }); + await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName); +}); diff --git a/apps/web/e2e/chat/chat-reaction.spec.ts b/apps/web/e2e/chat/chat-reaction.spec.ts deleted file mode 100644 index 25897daf..00000000 --- a/apps/web/e2e/chat/chat-reaction.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - addGroupMemberViaInvite, - chatMessage, - chatRow, - createGroupChat, - openChatInbox, - reactToChatMessage, - reloadAppPage, - sendChatMessage, - signIn, - uniqueSuffix, - uniqueTestUser -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('a chat reaction remains visible after a page reload', async ({ browser, page }) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const member = uniqueTestUser('reaction-member'); - await addGroupMemberViaInvite(browser, page, member); - await openChatInbox(page); - const title = `Reactions ${suffix}`; - await createGroupChat(page, { memberName: member.name, title }); - const message = `A great plan ${suffix}`; - await sendChatMessage(page, message); - // The composer shows optimistic messages before the server confirms them. - await expect(chatRow(page, title)).toContainText(message); - await reloadAppPage(page); - await expect(chatMessage(page, message)).toBeVisible(); - await reactToChatMessage(page, message, 'Agree'); - await reloadAppPage(page); - await expect( - chatMessage(page, message).getByRole('button', { name: 'Agree', exact: true }) - ).toBeVisible(); -}); diff --git a/apps/web/e2e/chat/chat-reaction.test.tsx b/apps/web/e2e/chat/chat-reaction.test.tsx new file mode 100644 index 00000000..cc3ece34 --- /dev/null +++ b/apps/web/e2e/chat/chat-reaction.test.tsx @@ -0,0 +1,29 @@ +import { + arrange, + chatMessage, + chatRow, + createGroupChat, + reactToChatMessage, + reloadAppPage, + sendChatMessage, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('a chat reaction remains visible after a page reload', async () => { + const suffix = uniqueSuffix(); + const member = await arrange.addMember('reaction-member'); + await openApp(); + const title = `Reactions ${suffix}`; + await createGroupChat({ memberName: member.name, title }); + const message = `A great plan ${suffix}`; + await sendChatMessage(message); + // The composer shows optimistic messages before the server confirms them. + await expect.element(chatRow(title)).toMatchTextContent(message); + await reloadAppPage(); + await expect.element(chatMessage(message)).toBeVisible(); + await reactToChatMessage(message, 'Agree'); + await reloadAppPage(); + await expect.element(chatMessage(message).getByRole('button', { name: 'Agree' })).toBeVisible(); +}); diff --git a/apps/web/e2e/chat/chat-with-the-group.spec.ts b/apps/web/e2e/chat/chat-with-the-group.spec.ts deleted file mode 100644 index c37a14f9..00000000 --- a/apps/web/e2e/chat/chat-with-the-group.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - addGroupMemberViaInvite, - chatRow, - createGroupChat, - openChatInbox, - sendMessage, - signIn, - uniqueSuffix, - uniqueTestUser -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('chats with a group member', async ({ browser, page }) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const member = uniqueTestUser('chat-member'); - await addGroupMemberViaInvite(browser, page, member); - - await openChatInbox(page); - await createGroupChat(page, { memberName: member.name, title: `Dates thread ${suffix}` }); - await sendMessage(page, { text: `Should we aim for late July? (${suffix})` }); - await openChatInbox(page); - await expect(chatRow(page, `Dates thread ${suffix}`)).toBeVisible(); -}); diff --git a/apps/web/e2e/chat/chat-with-the-group.test.tsx b/apps/web/e2e/chat/chat-with-the-group.test.tsx new file mode 100644 index 00000000..05c451ba --- /dev/null +++ b/apps/web/e2e/chat/chat-with-the-group.test.tsx @@ -0,0 +1,21 @@ +import { + arrange, + chatRow, + createGroupChat, + openChatInbox, + sendChatMessage, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('chats with a group member', async () => { + const suffix = uniqueSuffix(); + const member = await arrange.addMember('chat-member'); + await openApp(); + + await createGroupChat({ memberName: member.name, title: `Dates thread ${suffix}` }); + await sendChatMessage(`Should we aim for late July? (${suffix})`); + await openChatInbox(); + await expect.element(chatRow(`Dates thread ${suffix}`)).toBeVisible(); +}); diff --git a/apps/web/e2e/group/invite-a-member.spec.ts b/apps/web/e2e/group/invite-a-member.spec.ts deleted file mode 100644 index fcc017ba..00000000 --- a/apps/web/e2e/group/invite-a-member.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - acceptGroupInvitation, - addIdea, - by, - createTrip, - ids, - inviteGroupMember, - openTripFromList, - openTrips, - redeemGroupInvitationCode, - signIn, - tripCard, - uniqueSuffix, - uniqueTestUser, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('copies and pastes an invitation code that joins a member to the group', async ({ - browser, - page -}) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const member = uniqueTestUser('trip-member'); - const tripName = `Shared trip ${suffix}`; - - await createTrip(page, { name: tripName }); - const invitationCode = await inviteGroupMember(page); - - const memberContext = await browser.newContext(); - const memberPage = await memberContext.newPage(); - await acceptGroupInvitation(memberPage, invitationCode, member); - await openTripFromList(memberPage, tripName); - await expect(by(memberPage, ids.tripRole)).toHaveAttribute('data-role', 'participant'); - await expect(by(memberPage, ids.sharedTripBanner)).toBeVisible(); - await expect(by(memberPage, ids.sharedTripNewIdea)).toBeVisible(); - await expect(memberPage.getByRole('button', { name: 'Edit details' })).toHaveCount(0); - - await addIdea(memberPage, { name: `member/idea-${suffix}` }); - await updateTrip(memberPage, { dateNotes: 'First week of May' }); - - await memberPage.close(); - await memberContext.close(); - - await openTrips(page); - await expect(tripCard(page, tripName)).toBeVisible(); -}); - -test('opens the group gracefully when the code belongs to an existing membership', async ({ - page -}) => { - test.setTimeout(90_000); - await signIn(page); - const groupName = await by(page, ids.groupSwitcher).getAttribute('data-group-name'); - if (!groupName) throw new Error('Expected an active group before redeeming its invitation'); - const invitationCode = await inviteGroupMember(page); - - await redeemGroupInvitationCode(page, invitationCode); - - await expect(by(page, ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName); - await expect(page.getByText('Unable to join group')).toHaveCount(0); -}); diff --git a/apps/web/e2e/group/invite-a-member.test.tsx b/apps/web/e2e/group/invite-a-member.test.tsx new file mode 100644 index 00000000..b92e040b --- /dev/null +++ b/apps/web/e2e/group/invite-a-member.test.tsx @@ -0,0 +1,65 @@ +import { + acceptGroupInvitation, + addIdea, + arrange, + by, + environment, + expectGone, + ids, + inviteGroupMember, + openSettingsGroupSection, + openTripFromList, + redeemGroupInvitationCode, + reloadAppPage, + signUp, + uniqueSuffix, + uniqueTestUser, + updateTrip +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp, openSignedOutApp } from '@/testing/open-app'; + +test('an invitation code copied from settings joins a new member to the group', async () => { + await openApp(); + await environment.allowClipboard(); + const invitationCode = await inviteGroupMember(); + + // The invited person's own screens are the next test; here they redeem the code in the backend. + const member = await arrange.redeemInvitation(invitationCode); + await reloadAppPage(); + await openSettingsGroupSection(); + await expect.element(page.getByText(member.name).first()).toBeVisible(); +}); + +test('a member pastes an invitation code, joins the group and plans on its trips', async () => { + const suffix = uniqueSuffix(); + const tripName = `Shared trip ${suffix}`; + const invitationCode = await arrange.createInvitation(); + await arrange.createTrip({ name: tripName }); + + await openSignedOutApp(); + await environment.allowClipboard(); + await signUp(uniqueTestUser('trip-member')); + await acceptGroupInvitation(invitationCode); + await openTripFromList(tripName); + await expect.element(by(ids.tripRole)).toHaveAttribute('data-role', 'participant'); + await expect.element(by(ids.sharedTripBanner)).toBeVisible(); + await expect.element(by(ids.sharedTripNewIdea)).toBeVisible(); + await expectGone(page.getByRole('button', { name: 'Edit details' })); + + await addIdea({ name: `member/idea-${suffix}` }); + await updateTrip({ dateNotes: 'First week of May' }); +}); + +test('opens the group gracefully when the code belongs to an existing membership', async () => { + const { groupName } = await arrange.account(); + await openApp(); + await environment.allowClipboard(); + const invitationCode = await inviteGroupMember(); + + await redeemGroupInvitationCode(invitationCode); + + await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', groupName); + await expectGone(page.getByText('Unable to join group')); +}); diff --git a/apps/web/e2e/group/switch-groups.spec.ts b/apps/web/e2e/group/switch-groups.spec.ts deleted file mode 100644 index 556de80b..00000000 --- a/apps/web/e2e/group/switch-groups.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - by, - createGroup, - createTrip, - deleteCurrentGroup, - ids, - openTrips, - signIn, - switchGroup, - tripCard, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('group switching isolates reactive Convex data', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const organizationName = `Isolation ${uniqueSuffix()}`; - const tripName = `Only in ${organizationName}`; - - await createGroup(page, { name: organizationName }); - - await createTrip(page, { name: tripName }); - await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName); - - await switchGroup(page, { name: 'Groam Demo' }); - await expect(tripCard(page, tripName)).toHaveCount(0); - - await switchGroup(page, { name: organizationName }); - await openTrips(page); - await expect(tripCard(page, tripName)).toBeVisible(); - - await deleteCurrentGroup(page, { fallbackGroupName: 'Groam Demo' }); -}); diff --git a/apps/web/e2e/group/switch-groups.test.tsx b/apps/web/e2e/group/switch-groups.test.tsx new file mode 100644 index 00000000..52d020cb --- /dev/null +++ b/apps/web/e2e/group/switch-groups.test.tsx @@ -0,0 +1,37 @@ +import { + arrange, + by, + createGroup, + createTripFromTrips, + deleteCurrentGroup, + expectGone, + ids, + openTrips, + switchGroup, + tripCard, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('group switching isolates reactive Convex data', async () => { + const { groupName: homeGroupName } = await arrange.account(); + await openApp(); + const organizationName = `Isolation ${uniqueSuffix()}`; + const tripName = `Only in ${organizationName}`; + + await createGroup({ name: organizationName }); + + await createTripFromTrips({ name: tripName }, 'empty'); + await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', tripName); + + await switchGroup({ name: homeGroupName }); + await expect.element(by(ids.groupSwitcher)).toHaveAttribute('data-group-name', homeGroupName); + await expectGone(tripCard(tripName)); + + await switchGroup({ name: organizationName }); + await openTrips(); + await expect.element(tripCard(tripName)).toBeVisible(); + + await deleteCurrentGroup({ fallbackGroupName: homeGroupName }); +}); diff --git a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts deleted file mode 100644 index 3a3fafbe..00000000 --- a/apps/web/e2e/ideas/activities/apply-idea-activity.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - acceptGroupInvitation, - addActivity, - addIdea, - addStop, - applyIdea, - approveIdea, - createTrip, - inviteGroupMember, - mockDestinationSearch, - openAppPath, - openIdeaFromList, - openItinerary, - openSharedTrip, - requestIdeaReview, - saveSevenDayRange, - signIn, - uniqueSuffix, - uniqueTestUser -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('an approved idea activity appears on the shared itinerary only after applying it', async ({ - browser, - page -}) => { - test.setTimeout(180_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const tripId = await createTrip(page, { name: `Applied itinerary ${suffix}` }); - const ideaName = `Add a waterfront walk ${suffix}`; - await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Porto', notes: 'River', startDay: '4', endDay: '5' }); - await addActivity(page, { destination: 'Porto, Portugal', title: 'Waterfront walk' }); - await openSharedTrip(page); - await openAppPath(page, `trips/${tripId}/itinerary`); - await expect(page.getByRole('region', { name: 'Day planner' })).toBeVisible(); - await expect(page.getByRole('region', { name: 'Day planner' })).not.toContainText( - 'Waterfront walk' - ); - await openIdeaFromList(page, ideaName); - await requestIdeaReview(page); - - const code = await inviteGroupMember(page); - const reviewerContext = await browser.newContext(); - try { - const reviewer = await reviewerContext.newPage(); - await acceptGroupInvitation(reviewer, code, uniqueTestUser('activity-reviewer')); - await openIdeaFromList(reviewer, ideaName); - await approveIdea(reviewer); - } finally { - await reviewerContext.close(); - } - - await openAppPath(page, `trips/${tripId}/itinerary`); - await expect(page.getByRole('region', { name: 'Day planner' })).toBeVisible(); - await expect(page.getByRole('region', { name: 'Day planner' })).not.toContainText( - 'Waterfront walk' - ); - await openIdeaFromList(page, ideaName); - await applyIdea(page); - await openSharedTrip(page); - await openAppPath(page, `trips/${tripId}/itinerary`); - await expect(page).toHaveURL(new RegExp(`/trips/${tripId}/itinerary`, 'u')); - await expect(page.getByRole('region', { name: 'Day planner' })).toContainText('Waterfront walk'); -}); diff --git a/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx b/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx new file mode 100644 index 00000000..6982d80b --- /dev/null +++ b/apps/web/e2e/ideas/activities/apply-idea-activity.test.tsx @@ -0,0 +1,52 @@ +import { + addActivity, + addStop, + applyIdea, + arrange, + environment, + expectPath, + openAppPath, + openIdea, + openIdeaFromList, + openSharedTrip, + requestIdeaReview, + saveSevenDayRange, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('an approved idea activity appears on the shared itinerary only after applying it', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const reviewer = await arrange.addMember('activity-reviewer'); + const sharedTripId = await arrange.createTrip({ name: `Applied itinerary ${suffix}` }); + const ideaName = `Add a waterfront walk ${suffix}`; + const idea = await arrange.startIdea({ name: ideaName, tripId: sharedTripId }); + await openApp(); + const { tripId } = await openIdea({ proposalId: idea.proposalId, tripId: sharedTripId }); + await saveSevenDayRange(); + await addStop({ first: true, name: 'Porto', notes: 'River', startDay: '4', endDay: '5' }); + await addActivity({ destination: 'Porto, Portugal', title: 'Waterfront walk' }); + await openSharedTrip(); + await openAppPath(`trips/${tripId}/itinerary`); + const planner = page.getByRole('region', { name: 'Day planner' }); + await expect.element(planner).toBeVisible(); + await expect.element(planner).not.toMatchTextContent('Waterfront walk'); + await openIdeaFromList({ title: ideaName }, 'continue'); + await requestIdeaReview(); + + // A second person reviews: their own screens are covered by view tests, so they act through the backend. + await arrange.approveIdea({ ideaName, memberEmail: reviewer.email, tripId: sharedTripId }); + + await openAppPath(`trips/${tripId}/itinerary`); + await expect.element(planner).toBeVisible(); + await expect.element(planner).not.toMatchTextContent('Waterfront walk'); + await openIdeaFromList({ title: ideaName }, 'view'); + await applyIdea(); + await openSharedTrip(); + await openAppPath(`trips/${tripId}/itinerary`); + await expectPath(new RegExp(`/trips/${tripId}/itinerary`, 'u')); + await expect.element(planner).toMatchTextContent('Waterfront walk'); +}); diff --git a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts b/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts deleted file mode 100644 index 7f80bed8..00000000 --- a/apps/web/e2e/ideas/activities/cancel-idea-activity-edit.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - addActivity, - addIdea, - addStop, - createTrip, - discardActivityChanges, - ids, - inlineActivityEditor, - keepActivityPlan, - mockDestinationSearch, - openActivityPlan, - openIdeaFromList, - openIdeaItinerary, - openItinerary, - saveSevenDayRange, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('cancelling edits or removal leaves the saved idea activity intact', async ({ page }) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Cancel edits ${suffix}` }); - const ideaName = `Keep the plan ${suffix}`; - await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Porto', notes: 'Riverside', startDay: '1', endDay: '3' }); - await addActivity(page, { - destination: 'Porto, Portugal', - address: 'Original meeting point', - title: 'Boat trip' - }); - - await discardActivityChanges(page, 'Boat trip', 'Unsaved meeting point'); - await keepActivityPlan(page, 'Boat trip'); - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await openActivityPlan(page, 'Boat trip'); - await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue( - 'Original meeting point' - ); -}); diff --git a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts deleted file mode 100644 index 90095f5f..00000000 --- a/apps/web/e2e/ideas/activities/edit-idea-activities.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - addActivity, - addIdea, - addStop, - continueActivityPlan, - createIssue, - createTrip, - editActivity, - ids, - inlineActivityEditor, - mockDestinationSearch, - openActivityPlan, - openIdeaComparison, - openIdeaFromList, - openIdeaItinerary, - openItinerary, - openSharedTrip, - openTripSection, - removeActivityPlan, - requestIdeaReview, - returnIdeaToEditing, - saveSevenDayRange, - signIn, - startActivityPlan, - startIdeaFromIssue, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('adds, edits and removes activities on a later-day idea from both entry points', async ({ - page -}) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const ideaName = `Later-day activities ${suffix}`; - await createTrip(page, { name: `Later-day trip ${suffix}` }); - await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '5' }); - - // Days 1–3 have no destination yet, so planning starts on Day 4. - await startActivityPlan(page); - const editor = inlineActivityEditor(page); - await expect(editor).toHaveAttribute('aria-label', 'Add an activity to Day 4'); - await continueActivityPlan(page, 'Porto river walk'); - await expect(editor).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Edit Porto river walk' })).toBeVisible(); - - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await editActivity(page, { title: 'Porto river walk', address: 'Ribeira, Porto' }); - await openActivityPlan(page, 'Porto river walk'); - await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue( - 'Ribeira, Porto' - ); - await removeActivityPlan(page, 'Porto river walk'); - await expect(page.getByRole('button', { name: 'Edit Porto river walk' })).toHaveCount(0); - - await addActivity(page, { destination: 'Porto, Portugal', title: 'Evening cruise' }); - await expect(page.getByRole('button', { name: 'Edit Evening cruise' })).toBeVisible(); -}); - -test('switches destination and day, returns from comparison, and submits activity changes for review', async ({ - page -}) => { - test.setTimeout(150_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const ideaName = `Review activity ${suffix}`; - await createTrip(page, { name: `Review itinerary ${suffix}` }); - await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '3' }); - await addStop(page, { name: 'Porto', notes: 'River', startDay: '4', endDay: '5' }); - - await startActivityPlan(page, 5); - await expect(inlineActivityEditor(page)).toContainText('Day 5 · Porto, Portugal'); - await continueActivityPlan(page, 'Cruise on the Douro'); - await expect(page.getByRole('region', { name: 'Day 5 schedule' })).toContainText( - 'Cruise on the Douro' - ); - - await openIdeaComparison(page); - await expect(page.getByTestId(ids.compareSharedTrip)).toContainText('Cruise on the Douro'); - await returnIdeaToEditing(page); - await editActivity(page, { title: 'Cruise on the Douro', address: 'Cais da Ribeira' }); - await openIdeaFromList(page, ideaName); - await openIdeaComparison(page); - await requestIdeaReview(page); - // Authors may still amend their working copy during review. - await openIdeaFromList(page, ideaName); - await expect(page.getByTestId(ids.ideaPrimaryAction)).toHaveText('Approve'); - await openIdeaItinerary(page); - await editActivity(page, { title: 'Cruise on the Douro', address: 'Cais da Ribeira 42' }); - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await openActivityPlan(page, 'Cruise on the Douro'); - await expect(inlineActivityEditor(page).getByTestId(ids.activityAddress)).toHaveValue( - 'Cais da Ribeira 42' - ); -}); - -test('adds an activity to an issue-linked idea without changing the shared trip', async ({ - page -}) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const tripId = await createTrip(page, { name: `Issue itinerary ${suffix}` }); - await createIssue(page, { - title: `Plan a walk ${suffix}`, - body: 'Add a day out to the trip', - tripId - }); - const ideaName = await startIdeaFromIssue(page); - await saveSevenDayRange(page); - await openIdeaItinerary(page); - await addStop(page, { name: 'Lisbon', notes: 'Walk', startDay: '1', endDay: '3' }); - await addActivity(page, { destination: 'Lisbon, Portugal', title: 'Walk through Alfama' }); - await openSharedTrip(page); - await openTripSection(page, 'itinerary'); - await expect(page.getByText('Walk through Alfama')).toHaveCount(0); - await openTripSection(page, 'ideas'); - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await expect(page.getByRole('button', { name: 'Edit Walk through Alfama' })).toBeVisible(); -}); diff --git a/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx b/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx new file mode 100644 index 00000000..688d86c5 --- /dev/null +++ b/apps/web/e2e/ideas/activities/edit-idea-activities.test.tsx @@ -0,0 +1,90 @@ +import { + addActivity, + addStop, + arrange, + by, + continueActivityPlan, + editActivity, + environment, + expectGone, + ids, + openAppPath, + openIdea, + openIdeaComparison, + openIdeaFromList, + openIdeaItinerary, + openIssue, + openSharedTrip, + openTripSection, + requestIdeaReview, + returnIdeaToEditing, + saveSevenDayRange, + startActivityPlan, + startIdeaFromIssue, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +// Component-level editor states (later-day entry points, add/edit/remove, address round trips) are +// left to view tests; these journeys keep the flows that cross pages. +test('plans an activity on an idea, compares it, and submits the change for review', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const ideaName = `Review activity ${suffix}`; + const tripId = await arrange.createTrip({ name: `Review itinerary ${suffix}` }); + const { proposalId } = await arrange.startIdea({ name: ideaName, tripId }); + await openApp(); + await openIdea({ proposalId, tripId }); + await saveSevenDayRange(); + await addStop({ first: true, name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '3' }); + await addStop({ name: 'Porto', notes: 'River', startDay: '4', endDay: '5' }); + + await startActivityPlan(5); + await continueActivityPlan('Cruise on the Douro'); + await expect + .element(page.getByRole('region', { name: 'Day 5 schedule' })) + .toMatchTextContent('Cruise on the Douro'); + + await openIdeaComparison(); + await expect.element(by(ids.compareSharedTrip)).toMatchTextContent('Cruise on the Douro'); + await returnIdeaToEditing(); + await editActivity({ title: 'Cruise on the Douro', address: 'Cais da Ribeira' }); + await openIdeaFromList({ title: ideaName }, 'continue'); + await openIdeaComparison(); + await requestIdeaReview(); + // Authors may still amend their working copy during review. + await openIdeaFromList({ title: ideaName }, 'view'); + await expect.element(by(ids.ideaPrimaryAction)).toMatchTextContent('Approve'); + await openIdeaItinerary(); + await editActivity({ title: 'Cruise on the Douro', address: 'Cais da Ribeira 42' }); +}); + +test('adds an activity to an issue-linked idea without changing the shared trip', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Issue itinerary ${suffix}` }); + const issueId = await arrange.createIssue({ + title: `Plan a walk ${suffix}`, + body: 'Add a day out to the trip', + tripId + }); + await openApp(); + await openIssue(issueId); + const ideaName = await startIdeaFromIssue(); + await saveSevenDayRange(); + await openIdeaItinerary(); + await addStop({ first: true, name: 'Lisbon', notes: 'Walk', startDay: '1', endDay: '3' }); + await addActivity({ destination: 'Lisbon, Portugal', title: 'Walk through Alfama' }); + await openSharedTrip(); + await openAppPath(`trips/${tripId}/itinerary`); + await expect.element(page.getByRole('region', { name: 'Day planner' })).toBeVisible(); + await expectGone(page.getByText('Walk through Alfama')); + await openTripSection('ideas'); + await openIdeaFromList({ name: ideaName }, 'continue'); + await openIdeaItinerary(); + await expect + .element(page.getByRole('button', { name: 'Edit Walk through Alfama' })) + .toBeVisible(); +}); diff --git a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts deleted file mode 100644 index 07c93fd1..00000000 --- a/apps/web/e2e/ideas/activities/edit-multiday-idea-activity.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - addIdea, - addStop, - continueActivityPlan, - createTrip, - ids, - inlineActivityEditor, - mockDestinationSearch, - openActivityPlan, - openItinerary, - saveSevenDayRange, - signIn, - startActivityPlan, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('editing from a continuation day shows the complete activity range', async ({ page }) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Multi-day plan ${suffix}` }); - await addIdea(page, { name: `Multi-day idea ${suffix}` }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '4', endDay: '6' }); - await startActivityPlan(page); - await continueActivityPlan(page, 'Three-day festival', { startDay: 4, endDay: 6 }); - await expect(page.getByRole('region', { name: 'Day 6 schedule' })).toContainText( - 'Three-day festival' - ); - await openActivityPlan(page, 'Three-day festival', 6); - const editor = inlineActivityEditor(page); - await expect(editor).toContainText('Days 4–6 · Porto, Portugal'); - await expect(editor.getByTestId(ids.activityTitle)).toHaveValue('Three-day festival'); - await expect(editor.getByTestId(ids.activitySubmit)).toBeEnabled(); -}); diff --git a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts b/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts deleted file mode 100644 index 0b1cfad2..00000000 --- a/apps/web/e2e/ideas/activities/inline-idea-activities.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - addIdea, - addInlineActivity, - addStop, - createTrip, - editInlineActivity, - ids, - mockDestinationSearch, - openItinerary, - saveSevenDayRange, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('plans morning and afternoon activities directly within an idea day', async ({ page }) => { - test.setTimeout(150_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Inline itinerary ${suffix}` }); - await addIdea(page, { name: `Plan this day ${suffix}` }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Lisbon', notes: 'Explore the city', startDay: '1', endDay: '3' }); - - const day = page.getByRole('region', { name: 'Day 2 schedule' }); - await expect(day.getByRole('button', { name: 'Add to day' })).toBeVisible(); - await addInlineActivity(page, { - day: 2, - notes: 'Meet beside the bakery', - period: 'morning', - title: 'Market breakfast' - }); - await addInlineActivity(page, { day: 2, period: 'afternoon', title: 'Riverside stroll' }); - - // The day schedule lists the morning plan before the afternoon plan. - const plans = day.getByRole('button', { name: /^Edit (?:Market breakfast|Riverside stroll)$/u }); - await expect(plans).toHaveCount(2); - await expect(plans.first()).toHaveAccessibleName('Edit Market breakfast'); - await expect(plans.last()).toHaveAccessibleName('Edit Riverside stroll'); - await editInlineActivity(page, { - address: 'Ribeira das Naus, Lisbon', - day: 2, - period: 'afternoon', - title: 'Riverside stroll' - }); - await expect(day.getByRole('button', { name: 'Edit Riverside stroll' })).toBeVisible(); - await expect(page.getByTestId(ids.activityInlineEditor)).toHaveCount(0); -}); - -test('switching a shared day’s destination keeps the inline activity draft', async ({ page }) => { - test.setTimeout(150_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Shared day ${suffix}` }); - await addIdea(page, { name: `Two places ${suffix}` }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Lisbon', notes: 'Old town', startDay: '1', endDay: '2' }); - await addStop(page, { name: 'Porto', notes: 'River', startDay: '2', endDay: '4' }); - - await addInlineActivity(page, { - day: 2, - destination: 'Porto, Portugal', - expectDayReset: true, - notes: 'Meet by the old bridge', - period: 'afternoon', - range: { startDay: 1, endDay: 2 }, - title: 'Riverside picnic' - }); - const sharedDay = page.getByRole('region', { name: 'Day 2 schedule' }); - // The saved plan's row names the destination chosen in the inline draft. - await expect( - sharedDay - .locator('[data-slot="timeline-item"]') - .filter({ has: page.getByRole('button', { name: 'Edit Riverside picnic' }) }) - ).toContainText('Porto'); -}); - -test('mobile day planning keeps the inline activity form in the schedule', async ({ page }) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Mobile day plan ${suffix}` }); - await addIdea(page, { name: `Mobile activities ${suffix}` }); - await saveSevenDayRange(page); - await openItinerary(page); - await addStop(page, { name: 'Porto', notes: 'River', startDay: '2', endDay: '3' }); - await page.setViewportSize({ width: 390, height: 844 }); - - const day = page.getByRole('region', { name: 'Day 2 schedule' }); - await expect(day.getByRole('button', { name: 'Add to day' })).toBeVisible(); - await addInlineActivity(page, { day: 2, period: 'morning', title: 'Morning market' }); - await expect(day.getByRole('button', { name: 'Edit Morning market' })).toBeVisible(); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); -}); diff --git a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts b/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts deleted file mode 100644 index 66946af1..00000000 --- a/apps/web/e2e/ideas/activities/undated-idea-activity.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - addActivity, - addFirstDestination, - addIdea, - createTrip, - editActivity, - mockDestinationSearch, - openIdeaFromList, - openIdeaItinerary, - openItinerary, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('plans and revisits an activity before trip dates or destination days are set', async ({ - page -}) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Flexible trip ${suffix}` }); - const ideaName = `Plan before dates ${suffix}`; - await addIdea(page, { name: ideaName }); - await openItinerary(page); - await addFirstDestination(page, { name: 'Porto', notes: 'River walk' }); - await addActivity(page, { destination: 'Porto, Portugal', title: 'Explore the old town' }); - await expect(page.getByRole('region', { name: 'Day 1 schedule' })).toContainText( - 'Explore the old town' - ); - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await editActivity(page, { title: 'Explore the old town', address: 'Ribeira, Porto' }); -}); diff --git a/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx b/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx new file mode 100644 index 00000000..0abfd7c6 --- /dev/null +++ b/apps/web/e2e/ideas/activities/undated-idea-activity.test.tsx @@ -0,0 +1,34 @@ +import { + addActivity, + addFirstDestination, + arrange, + editActivity, + environment, + openIdea, + openIdeaFromList, + openTripSection, + openIdeaItinerary, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('plans and revisits an activity before trip dates or destination days are set', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Flexible trip ${suffix}` }); + const ideaName = `Plan before dates ${suffix}`; + const { proposalId } = await arrange.startIdea({ name: ideaName, tripId }); + await openApp(); + await openIdea({ proposalId, tripId }); + await openTripSection('itinerary'); + await addFirstDestination({ name: 'Porto', notes: 'River walk' }); + await addActivity({ destination: 'Porto, Portugal', title: 'Explore the old town' }); + await expect + .element(page.getByRole('region', { name: 'Day 1 schedule' })) + .toMatchTextContent('Explore the old town'); + await openIdeaFromList({ title: ideaName }, 'continue'); + await openIdeaItinerary(); + await editActivity({ title: 'Explore the old town', address: 'Ribeira, Porto' }); +}); diff --git a/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts b/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts deleted file mode 100644 index 7699b63b..00000000 --- a/apps/web/e2e/ideas/planning/destination-details-locator.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ids, openDestinationDetails } from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -for (const label of [ - 'Open Porto, Portugal details for Day 1', - 'Manage Porto, Portugal for Day 1' -]) { - test(`opens destination details from ${label}`, async ({ page }) => { - await page.setContent(` - - - `); - await page.evaluate(() => { - document.querySelector('button')?.addEventListener('click', () => { - document.querySelector('section')?.removeAttribute('hidden'); - }); - }); - - await openDestinationDetails(page, 'Porto, Portugal'); - - await expect(page.getByTestId(ids.destinationCard)).toBeVisible(); - }); -} diff --git a/apps/web/e2e/ideas/planning/idea-layout.spec.ts b/apps/web/e2e/ideas/planning/idea-layout.spec.ts deleted file mode 100644 index 21b53619..00000000 --- a/apps/web/e2e/ideas/planning/idea-layout.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - addIdea, - by, - createTrip, - ids, - openIdeaComparison, - openIdeaFromList, - openTripSection, - openWorkspaceIdeas, - pageCanvas, - resolvedBackground, - signIn, - twoColumnPageLayout, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('idea tabs share the page canvas while itinerary uses its full width', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - await createTrip(page, { name: `Layout defaults ${uniqueSuffix()}` }); - const ideaName = `Consistent tabs ${uniqueSuffix()}`; - await addIdea(page, { name: ideaName }); - await openWorkspaceIdeas(page); - const sidebarLink = await by(page, ids.navTrips).elementHandle(); - if (!sidebarLink) throw new Error('Expected workspace navigation'); - await openIdeaFromList(page, ideaName); - await expect(by(page, ids.tripHeading)).toContainText(ideaName); - expect(await sidebarLink.evaluate((element) => element.isConnected)).toBe(true); - - // Trip pages size their supporting column from the trip workspace container (18rem). - const tripRightColumn = 288; - for (const width of [1440, 1024, 390]) { - await page.setViewportSize({ width, height: 1000 }); - await openTripSection(page, 'overview'); - const overview = await twoColumnPageLayout(page); - const canvas = await pageCanvas(page); - expect(overview.overflow).toBe(false); - expect(overview.stacked).toBe(width < 1280); - if (width >= 1280) expect(overview.rightWidth).toBe(tripRightColumn); - await openTripSection(page, 'itinerary'); - // The itinerary editor is a single full-width section without a supporting column. - await expect(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible(); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - width - ); - const itineraryWidth = await page.locator('[data-slot="shell-page-body"]').evaluate((body) => { - const section = body.querySelector('section[aria-label="Itinerary editor"]'); - if (!section) throw new Error('Expected the itinerary content section'); - const style = getComputedStyle(body); - return { - available: Math.round( - body.clientWidth - - Number.parseFloat(style.paddingLeft) - - Number.parseFloat(style.paddingRight) - ), - content: Math.round(section.getBoundingClientRect().width) - }; - }); - expect(itineraryWidth.content).toBe(itineraryWidth.available); - await expect.poll(() => pageCanvas(page)).toEqual(canvas); - - await openIdeaComparison(page); - await expect.poll(() => twoColumnPageLayout(page)).toEqual(overview); - await expect.poll(() => pageCanvas(page)).toEqual(canvas); - } -}); - -test('all shared trip tabs retain the Overview canvas', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - await createTrip(page, { name: `Trip canvas ${uniqueSuffix()}` }); - for (const dark of [false, true]) { - await page.evaluate( - (enabled) => document.documentElement.classList.toggle('dark', enabled), - dark - ); - for (const width of [1440, 390]) { - await page.setViewportSize({ width, height: 1000 }); - await openTripSection(page, 'overview'); - const overview = await pageCanvas(page); - expect(overview.overflow).toBe(false); - expect(overview.background).toBe(await resolvedBackground(page, 'var(--canvas)')); - await expect(page.locator('[data-slot=sidebar-wrapper]')).toHaveCSS( - 'background-color', - await resolvedBackground(page, 'transparent') - ); - for (const tab of ['issues', 'ideas', 'activity'] as const) { - await openTripSection(page, tab); - await expect.poll(() => pageCanvas(page)).toEqual(overview); - } - } - } -}); diff --git a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts deleted file mode 100644 index b4f0249e..00000000 --- a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - addActivity, - addIdea, - addStop, - createTrip, - editActivity, - mockDestinationSearch, - openItinerary, - openTrips, - saveSevenDayRange, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('plans a trip itinerary on an idea working copy', async ({ page }) => { - test.setTimeout(180_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const tripName = `Itinerary ${suffix}`; - const ideaName = `dates/seven-day-plan-${suffix}`; - - await createTrip(page, { name: tripName }); - await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - - await updateTrip(page, { budgetAmount: 1800 }); - - await openItinerary(page); - await addStop(page, { endDay: '3', name: 'Lisbon', notes: 'Food and old town', startDay: '1' }); - await addStop(page, { endDay: '5', name: 'Porto', notes: 'River and cellars', startDay: '4' }); - - await addActivity(page, { - destination: 'Lisbon, Portugal', - address: 'Miradouro da Senhora do Monte, Lisbon, Portugal', - notes: 'Meet by the tram stop', - title: 'Sunset viewpoint walk' - }); - await addActivity(page, { - destination: 'Lisbon, Portugal', - address: 'Rua da Bica 18, Lisbon, Portugal', - title: 'Dinner reservation' - }); - - await editActivity(page, { - address: 'Miradouro da Senhora do Monte, 1170-107 Lisbon, Portugal', - title: 'Sunset viewpoint walk' - }); - await expect(page.getByRole('tablist', { name: 'Planning tools' })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Choose a day' })).toHaveCount(0); - await expect(page.getByRole('region', { name: 'Day 1 schedule', exact: true })).toBeVisible(); - await expect(page.getByRole('region', { name: 'Day 7 schedule', exact: true })).toBeVisible(); - await openTrips(page); -}); diff --git a/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx new file mode 100644 index 00000000..4883c47a --- /dev/null +++ b/apps/web/e2e/ideas/planning/plan-a-trip-itinerary.test.tsx @@ -0,0 +1,57 @@ +import { + addActivity, + addStop, + arrange, + editActivity, + environment, + openIdea, + openTrips, + saveSevenDayRange, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('plans a trip itinerary on an idea working copy', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const tripName = `Itinerary ${suffix}`; + const ideaName = `dates/seven-day-plan-${suffix}`; + + const tripId = await arrange.createTrip({ name: tripName }); + const idea = await arrange.startIdea({ name: ideaName, tripId }); + await arrange.updateTrip({ budgetAmount: 1800, tripId: idea.workingTripId }); + await openApp(); + await openIdea({ proposalId: idea.proposalId, tripId }); + await saveSevenDayRange(); + + await addStop({ + endDay: '3', + first: true, + name: 'Lisbon', + notes: 'Food and old town', + startDay: '1' + }); + await addStop({ endDay: '5', name: 'Porto', notes: 'River and cellars', startDay: '4' }); + + await addActivity({ + destination: 'Lisbon, Portugal', + address: 'Miradouro da Senhora do Monte, Lisbon, Portugal', + notes: 'Meet by the tram stop', + title: 'Sunset viewpoint walk' + }); + await addActivity({ + destination: 'Lisbon, Portugal', + address: 'Rua da Bica 18, Lisbon, Portugal', + title: 'Dinner reservation' + }); + + await editActivity({ + address: 'Miradouro da Senhora do Monte, 1170-107 Lisbon, Portugal', + title: 'Sunset viewpoint walk' + }); + await expect.element(page.getByRole('region', { name: 'Day 1 schedule' })).toBeVisible(); + await expect.element(page.getByRole('region', { name: 'Day 7 schedule' })).toBeVisible(); + await openTrips(); +}); diff --git a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts b/apps/web/e2e/ideas/planning/start-an-idea.spec.ts deleted file mode 100644 index 99b2b986..00000000 --- a/apps/web/e2e/ideas/planning/start-an-idea.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - addIdea, - by, - createTrip, - ideaRow, - ids, - openIdeaFromList, - openSharedTrip, - openTripSection, - openWorkspaceIdeas, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('starts an idea and opens first-class idea routes', async ({ page }) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const tripName = `Idea routes ${suffix}`; - const ideaName = `routes/open-idea-${suffix}`; - - const tripId = await createTrip(page, { name: tripName }); - await addIdea(page, { name: ideaName }); - await openSharedTrip(page); - await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', tripName); - await openTripSection(page, 'ideas'); - - const draftRow = ideaRow(page, ideaName); - await expect(draftRow.getByTestId(ids.ideaContinue)).toBeVisible(); - const proposalId = await draftRow.getAttribute('data-idea-id'); - expect(proposalId).toBeTruthy(); - - await openWorkspaceIdeas(page); - await expect(ideaRow(page, ideaName)).toBeVisible(); - - await openIdeaFromList(page, ideaName); - await expect(page).toHaveURL( - new RegExp(`/trips/${tripId}/ideas/[a-z]{6}/overview(?:\\?.*)?$`, 'u') - ); -}); diff --git a/apps/web/e2e/ideas/planning/start-an-idea.test.tsx b/apps/web/e2e/ideas/planning/start-an-idea.test.tsx new file mode 100644 index 00000000..5ab062a4 --- /dev/null +++ b/apps/web/e2e/ideas/planning/start-an-idea.test.tsx @@ -0,0 +1,40 @@ +import { + addIdea, + arrange, + by, + expectPath, + ideaRow, + ids, + openIdeaFromList, + openSharedTrip, + openTrip, + openTripSection, + openWorkspaceIdeas, + presentAttribute, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('starts an idea and opens first-class idea routes', async () => { + const suffix = uniqueSuffix(); + const tripName = `Idea routes ${suffix}`; + const ideaName = `routes/open-idea-${suffix}`; + + await openApp(); + const tripId = await openTrip(await arrange.createTrip({ name: tripName })); + await addIdea({ name: ideaName }); + await openSharedTrip(); + await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', tripName); + await openTripSection('ideas'); + + const draftRow = ideaRow({ title: ideaName }); + await expect.element(by(ids.ideaContinue, draftRow)).toBeVisible(); + await presentAttribute(draftRow, 'data-idea-id'); + + await openWorkspaceIdeas(); + await expect.element(ideaRow({ title: ideaName })).toBeVisible(); + + await openIdeaFromList({ title: ideaName }, 'continue'); + await expectPath(new RegExp(`/trips/${tripId}/ideas/[a-z]{6}/overview(?:\\?.*)?$`, 'u')); +}); diff --git a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts b/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts deleted file mode 100644 index ea405e03..00000000 --- a/apps/web/e2e/ideas/planning/start-idea-from-workspace.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - by, - createTrip, - ids, - openSharedTrip, - signIn, - startWorkspaceIdea, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('starts a new idea from workspace Ideas for the selected trip', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - const firstName = `Unselected trip ${suffix}`; - const selectedName = `Selected trip ${suffix}`; - await createTrip(page, { name: firstName }); - const selectedTripId = await createTrip(page, { name: selectedName }); - const ideaName = `Change the selected trip ${suffix}`; - - await startWorkspaceIdea(page, { tripName: selectedName, title: ideaName }); - await expect(page).toHaveURL(new RegExp(`/trips/${selectedTripId}/ideas/[^/]+/overview`, 'u')); - await expect(page.getByRole('heading', { name: ideaName })).toBeVisible(); - await expect(by(page, ids.ideaWorkspace)).toHaveAttribute('data-idea-name', /.+/u); - await openSharedTrip(page); - await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', selectedName); -}); diff --git a/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx b/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx new file mode 100644 index 00000000..8541c8c4 --- /dev/null +++ b/apps/web/e2e/ideas/planning/start-idea-from-workspace.test.tsx @@ -0,0 +1,29 @@ +import { + arrange, + by, + expectPath, + ids, + openSharedTrip, + startWorkspaceIdea, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('starts a new idea from workspace Ideas for the selected trip', async () => { + const suffix = uniqueSuffix(); + const firstName = `Unselected trip ${suffix}`; + const selectedName = `Selected trip ${suffix}`; + await arrange.createTrip({ name: firstName }); + await arrange.createTrip({ name: selectedName }); + await openApp(); + const ideaName = `Change the selected trip ${suffix}`; + + await startWorkspaceIdea({ tripName: selectedName, title: ideaName }); + // The shared trip check below proves the idea belongs to the selected trip. + await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/overview/u); + await expect.element(page.getByRole('heading', { name: ideaName })).toBeVisible(); + await openSharedTrip(); + await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', selectedName); +}); diff --git a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts b/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts deleted file mode 100644 index 1222ba20..00000000 --- a/apps/web/e2e/ideas/planning/start-with-first-destination.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - addFirstDestination, - addIdea, - by, - createTrip, - ids, - mockDestinationSearch, - openDestinationDetails, - openTripSection, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('starts an idea and adds the first destination through one composer', async ({ page }) => { - test.setTimeout(120_000); - await mockDestinationSearch(page); - await signIn(page); - await createTrip(page, { name: `First destination ${uniqueSuffix()}` }); - await expect(by(page, ids.tripActionAddStop)).toHaveCount(0); - await addIdea(page, { name: `First stop idea ${uniqueSuffix()}` }); - await openTripSection(page, 'itinerary'); - - await expect(by(page, ids.startIdeaDialog)).toHaveCount(0); - await expect(page).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u, { - timeout: 20_000 - }); - await addFirstDestination(page, { - initialSearch: 'Lisbon', - name: 'Porto', - notes: 'Food and old town' - }); - await openDestinationDetails(page, 'Porto, Portugal'); - await expect(page).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u); -}); diff --git a/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx b/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx new file mode 100644 index 00000000..35716368 --- /dev/null +++ b/apps/web/e2e/ideas/planning/start-with-first-destination.test.tsx @@ -0,0 +1,37 @@ +import { + addFirstDestination, + addIdea, + arrange, + by, + destinationCard, + environment, + expectGone, + expectPath, + ids, + openDestinationDetails, + openTrip, + openTripSection, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('starts an idea and adds the first destination through one composer', async () => { + await environment.mockDestinationSearch(); + await openApp(); + await openTrip(await arrange.createTrip({ name: `First destination ${uniqueSuffix()}` })); + await expectGone(by(ids.tripActionAddStop)); + await addIdea({ name: `First stop idea ${uniqueSuffix()}` }); + await openTripSection('itinerary'); + + await expectGone(by(ids.startIdeaDialog)); + await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u); + await addFirstDestination({ + initialSearch: 'Lisbon', + name: 'Porto', + notes: 'Food and old town' + }); + await openDestinationDetails('Porto, Portugal'); + await expectPath(/\/trips\/[^/]+\/ideas\/[^/]+\/itinerary/u); + await expect.element(destinationCard('Porto, Portugal')).toBeVisible(); +}); diff --git a/apps/web/e2e/ideas/review/close-idea.spec.ts b/apps/web/e2e/ideas/review/close-idea.spec.ts deleted file mode 100644 index 4358d2a3..00000000 --- a/apps/web/e2e/ideas/review/close-idea.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - addIdea, - closeIdea, - createTrip, - filterWorkspaceIdeas, - ideaRow, - ids, - requestIdeaReview, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('deletes a draft idea without leaving an editable working copy', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Abandoned trip ${suffix}` }); - const name = `Abandoned idea ${suffix}`; - await addIdea(page, { name }); - await closeIdea(page); - await filterWorkspaceIdeas(page, 'Settled', [name]); - const row = ideaRow(page, name); - await expect(row).toHaveAttribute('data-status', 'closed'); - await expect(row.getByTestId(ids.ideaContinue)).toHaveCount(0); -}); - -test('requires a reason to pass on an idea already in review', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Review trip ${suffix}` }); - const name = `Pass on idea ${suffix}`; - await addIdea(page, { name }); - await updateTrip(page, { dateNotes: 'Try a different week' }); - await requestIdeaReview(page); - await closeIdea(page, 'The original week works better'); - await filterWorkspaceIdeas(page, 'Settled', [name]); - const row = ideaRow(page, name); - await expect(row).toHaveAttribute('data-status', 'closed'); - await expect(row.getByTestId(ids.ideaContinue)).toHaveCount(0); -}); diff --git a/apps/web/e2e/ideas/review/close-idea.test.tsx b/apps/web/e2e/ideas/review/close-idea.test.tsx new file mode 100644 index 00000000..1a35e878 --- /dev/null +++ b/apps/web/e2e/ideas/review/close-idea.test.tsx @@ -0,0 +1,43 @@ +import { + arrange, + by, + closeIdea, + filterWorkspaceIdeas, + ideaRow, + ids, + expectGone, + openIdea, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('deletes a draft idea without leaving an editable working copy', async () => { + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Abandoned trip ${suffix}` }); + const name = `Abandoned idea ${suffix}`; + const { proposalId } = await arrange.startIdea({ name, tripId }); + await openApp(); + await openIdea({ proposalId, tripId }); + await closeIdea(); + await filterWorkspaceIdeas('Settled', [name]); + const row = ideaRow({ title: name }); + await expect.element(row).toHaveAttribute('data-status', 'closed'); + await expectGone(by(ids.ideaContinue, row)); +}); + +test('requires a reason to pass on an idea already in review', async () => { + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Review trip ${suffix}` }); + const name = `Pass on idea ${suffix}`; + const idea = await arrange.startIdea({ name, tripId }); + await arrange.updateTrip({ dateNotes: 'Try a different week', tripId: idea.workingTripId }); + await arrange.requestIdeaReview(idea.proposalId); + await openApp(); + await openIdea({ proposalId: idea.proposalId, tripId }); + await closeIdea('The original week works better'); + await filterWorkspaceIdeas('Settled', [name]); + const row = ideaRow({ title: name }); + await expect.element(row).toHaveAttribute('data-status', 'closed'); + await expectGone(by(ids.ideaContinue, row)); +}); diff --git a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts b/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts deleted file mode 100644 index 861338c9..00000000 --- a/apps/web/e2e/ideas/review/idea-apply-gate.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - addIdea, - by, - createTrip, - ids, - reloadAppPage, - requestIdeaReview, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('submitting an idea for review does not allow applying it without approval', async ({ - page -}) => { - test.setTimeout(90_000); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Unapproved trip ${suffix}` }); - await addIdea(page, { name: `Unapproved idea ${suffix}` }); - await updateTrip(page, { dateNotes: 'A slower weekend' }); - await requestIdeaReview(page); - - await expect(by(page, ids.applyIdea).first()).toBeVisible(); - await expect(by(page, ids.applyIdea).first()).toBeDisabled(); - await reloadAppPage(page); - await expect(by(page, ids.applyIdea).first()).toBeDisabled(); -}); diff --git a/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx b/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx new file mode 100644 index 00000000..78b2ef6e --- /dev/null +++ b/apps/web/e2e/ideas/review/idea-apply-gate.test.tsx @@ -0,0 +1,27 @@ +import { + arrange, + by, + ids, + openIdea, + openIdeaComparison, + reloadAppPage, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('submitting an idea for review does not allow applying it without approval', async () => { + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Unapproved trip ${suffix}` }); + const idea = await arrange.startIdea({ name: `Unapproved idea ${suffix}`, tripId }); + await arrange.updateTrip({ dateNotes: 'A slower weekend', tripId: idea.workingTripId }); + await arrange.requestIdeaReview(idea.proposalId); + await openApp(); + await openIdea({ proposalId: idea.proposalId, tripId }); + await openIdeaComparison(); + + await expect.element(by(ids.applyIdea).first()).toBeVisible(); + await expect.element(by(ids.applyIdea).first()).toBeDisabled(); + await reloadAppPage(); + await expect.element(by(ids.applyIdea).first()).toBeDisabled(); +}); diff --git a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts b/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts deleted file mode 100644 index 93ba6f4b..00000000 --- a/apps/web/e2e/ideas/review/idea-review-requires-change.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - addIdea, - by, - createTrip, - ids, - openIdeaComparison, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('an empty idea cannot be submitted until it contains a change', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Empty proposal ${suffix}` }); - await addIdea(page, { name: `Fill this idea ${suffix}` }); - await openIdeaComparison(page); - await expect(by(page, ids.requestReview).first()).toBeDisabled(); - await expect(page.getByText('Add a change before you can send this to the group.')).toBeVisible(); - - await updateTrip(page, { dateNotes: 'Travel after the holidays' }); - await openIdeaComparison(page); - await expect(by(page, ids.requestReview).first()).toBeEnabled(); -}); diff --git a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts b/apps/web/e2e/ideas/review/review-idea-changes.spec.ts deleted file mode 100644 index d58b44df..00000000 --- a/apps/web/e2e/ideas/review/review-idea-changes.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - addIdea, - by, - createTrip, - ids, - returnIdeaToEditing, - reviewIdeaChange, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('reviews an edit, comments without blocking it, and returns to editing on mobile', async ({ - page -}) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - await createTrip(page, { name: `Review polish ${suffix}` }); - await addIdea(page, { name: `Review dates ${suffix}` }); - const note = `Prefer a relaxed weekend ${suffix}`; - await updateTrip(page, { dateNotes: note }); - const comment = `Looks good, keeping this flexible. ${suffix}`; - await reviewIdeaChange(page, { change: note, comment }); - await expect(by(page, ids.requestReview)).toBeEnabled(); - await page.setViewportSize({ width: 390, height: 844 }); - await returnIdeaToEditing(page); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); -}); diff --git a/apps/web/e2e/ideas/review/review-idea-changes.test.tsx b/apps/web/e2e/ideas/review/review-idea-changes.test.tsx new file mode 100644 index 00000000..96d2e9e0 --- /dev/null +++ b/apps/web/e2e/ideas/review/review-idea-changes.test.tsx @@ -0,0 +1,27 @@ +import { + arrange, + by, + ids, + openIdea, + returnIdeaToEditing, + reviewIdeaChange, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +// Collapsing and expanding a change card, and the mobile no-overflow check, are component-level +// and left to view tests. +test('reviews an edit, comments without blocking it, and returns to editing', async () => { + const suffix = uniqueSuffix(); + const tripId = await arrange.createTrip({ name: `Review polish ${suffix}` }); + const idea = await arrange.startIdea({ name: `Review dates ${suffix}`, tripId }); + const note = `Prefer a relaxed weekend ${suffix}`; + await arrange.updateTrip({ dateNotes: note, tripId: idea.workingTripId }); + await openApp(); + await openIdea({ proposalId: idea.proposalId, tripId }); + const comment = `Looks good, keeping this flexible. ${suffix}`; + await reviewIdeaChange({ change: note, comment }); + await expect.element(by(ids.requestReview)).toBeEnabled(); + await returnIdeaToEditing(); +}); diff --git a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts b/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts deleted file mode 100644 index 5fe8e7e6..00000000 --- a/apps/web/e2e/ideas/review/review-idea-permissions.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - acceptGroupInvitation, - addActivity, - addIdea, - addStop, - approveIdea, - createTrip, - ids, - inviteGroupMember, - mockDestinationSearch, - openAppPath, - openIdeaComparison, - openIdeaFromList, - openIdeaItinerary, - openTripSection, - requestIdeaReview, - saveSevenDayRange, - signIn, - uniqueSuffix, - uniqueTestUser, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('reviewers can inspect and approve an idea but cannot edit its working copy', async ({ - browser, - page -}) => { - test.setTimeout(180_000); - await mockDestinationSearch(page); - await signIn(page); - const suffix = uniqueSuffix(); - const tripId = await createTrip(page, { name: `Read-only review ${suffix}` }); - const ideaName = `Review-only idea ${suffix}`; - const proposalId = await addIdea(page, { name: ideaName }); - await saveSevenDayRange(page); - await openTripSection(page, 'overview'); - await addStop(page, { name: 'Porto', notes: 'River walk', startDay: '1', endDay: '3' }); - await addActivity(page, { destination: 'Porto, Portugal', title: 'Riverside walk' }); - await updateTrip(page, { dateNotes: 'Prefer more time on the coast' }); - await requestIdeaReview(page); - - const code = await inviteGroupMember(page); - const reviewerContext = await browser.newContext(); - try { - const reviewer = await reviewerContext.newPage(); - await acceptGroupInvitation(reviewer, code, uniqueTestUser('reviewer')); - await openIdeaFromList(reviewer, ideaName); - await expect(reviewer.getByTestId(ids.ideaPrimaryAction)).toHaveText('Approve'); - await expect(reviewer.getByTestId(ids.tripActionEdit)).toHaveCount(0); - await expect(reviewer.getByTestId(ids.ideaSectionCompare)).toBeVisible(); - // A direct itinerary URL must not expose the editor to a reviewer either. - await openAppPath(reviewer, `trips/${tripId}/ideas/${proposalId}/itinerary`); - await expect(reviewer.getByText('Riverside walk', { exact: true })).toBeVisible(); - await expect(reviewer.getByRole('region', { name: 'Itinerary editor' })).toHaveCount(0); - await expect(reviewer.getByRole('button', { name: 'Add a plan' })).toHaveCount(0); - await expect( - reviewer.getByRole('button', { name: 'Add destination', exact: true }) - ).toHaveCount(0); - await expect(reviewer.getByRole('button', { name: 'Add to day', exact: true })).toHaveCount(0); - await expect(reviewer.getByRole('button', { name: 'Edit Riverside walk' })).toHaveCount(0); - await openIdeaComparison(reviewer); - await approveIdea(reviewer); - await expect(reviewer.getByTestId(ids.approveIdea).first()).toHaveText(/Remove approval/u); - - await openIdeaFromList(page, ideaName); - await openIdeaItinerary(page); - await expect(page.getByRole('button', { name: 'Edit Riverside walk' })).toBeVisible(); - } finally { - await reviewerContext.close(); - } -}); diff --git a/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx b/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx new file mode 100644 index 00000000..b12c88f5 --- /dev/null +++ b/apps/web/e2e/ideas/review/review-idea-permissions.test.tsx @@ -0,0 +1,63 @@ +import { + addActivity, + addStop, + approveIdea, + arrange, + by, + environment, + expectGone, + ids, + openAppPath, + openIdea, + openIdeaComparison, + openIdeaFromList, + openIdeaItinerary, + openTripSection, + requestIdeaReview, + saveSevenDayRange, + uniqueSuffix, + updateTrip +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('reviewers can inspect and approve an idea but cannot edit its working copy', async () => { + await environment.mockDestinationSearch(); + const suffix = uniqueSuffix(); + const reviewer = await arrange.addMember('reviewer'); + const tripId = await arrange.createTrip({ name: `Read-only review ${suffix}` }); + const ideaName = `Review-only idea ${suffix}`; + const { proposalId } = await arrange.startIdea({ name: ideaName, tripId }); + await openApp(); + await openIdea({ proposalId, tripId }); + await saveSevenDayRange(); + await openTripSection('overview'); + await addStop({ first: true, name: 'Porto', notes: 'River walk', startDay: '1', endDay: '3' }); + await addActivity({ destination: 'Porto, Portugal', title: 'Riverside walk' }); + await updateTrip({ dateNotes: 'Prefer more time on the coast' }); + await requestIdeaReview(); + + // The same browser now belongs to the reviewer, one person at a time. + await openApp('/trips', { as: reviewer }); + await openIdeaFromList({ title: ideaName }, 'view'); + await expect.element(by(ids.ideaPrimaryAction)).toMatchTextContent('Approve'); + await expectGone(by(ids.tripActionEdit)); + await expect.element(by(ids.ideaSectionCompare)).toBeVisible(); + // A direct itinerary URL must not expose the editor to a reviewer either. + await openAppPath(`trips/${tripId}/ideas/${proposalId}/itinerary`); + await expect.element(page.getByText('Riverside walk')).toBeVisible(); + await expectGone(page.getByRole('region', { name: 'Itinerary editor' })); + await expectGone(page.getByRole('button', { name: 'Add a plan' })); + await expectGone(page.getByRole('button', { name: 'Add destination' })); + await expectGone(page.getByRole('button', { name: 'Add to day' })); + await expectGone(page.getByRole('button', { name: 'Edit Riverside walk' })); + await openIdeaComparison(); + await approveIdea(); + await expect.element(by(ids.approveIdea).first()).toMatchTextContent(/Remove approval/u); + + await openApp(); + await openIdeaFromList({ title: ideaName }, 'view'); + await openIdeaItinerary(); + await expect.element(page.getByRole('button', { name: 'Edit Riverside walk' })).toBeVisible(); +}); diff --git a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts b/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts deleted file mode 100644 index e7040ee1..00000000 --- a/apps/web/e2e/ideas/review/reviewer-layout.visual.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ids, openBackgroundActivity, signIn } from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('background activity is discoverable in settings and opens a real run', async ({ page }) => { - await signIn(page); - await expect(page.getByRole('complementary').getByTestId(ids.navAgents)).toHaveCount(0); - const result = await openBackgroundActivity(page); - if (result === 'empty') { - await expect(page.getByText('Nothing running yet')).toBeVisible(); - } - await page.screenshot({ path: 'test-results/background-activity.png' }); -}); diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts b/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts deleted file mode 100644 index 998fdfd2..00000000 --- a/apps/web/e2e/ideas/review/revoke-idea-approval.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - acceptGroupInvitation, - addIdea, - approveIdea, - by, - createTrip, - ids, - inviteGroupMember, - openIdeaFromList, - requestIdeaReview, - revokeIdeaApproval, - signIn, - uniqueSuffix, - uniqueTestUser, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('revoking the only approval prevents an idea from being applied', async ({ - browser, - page -}) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const ideaName = `Approval reversal ${suffix}`; - await createTrip(page, { name: `Review reversal ${suffix}` }); - await addIdea(page, { name: ideaName }); - await updateTrip(page, { dateNotes: 'More time by the river' }); - await requestIdeaReview(page); - - const code = await inviteGroupMember(page); - const reviewerContext = await browser.newContext(); - try { - const reviewer = await reviewerContext.newPage(); - await acceptGroupInvitation(reviewer, code, uniqueTestUser('approval-reversal')); - await openIdeaFromList(reviewer, ideaName); - await approveIdea(reviewer); - await openIdeaFromList(page, ideaName); - await expect(by(page, ids.applyIdea).first()).toBeEnabled(); - - await revokeIdeaApproval(reviewer); - await expect(by(reviewer, ids.approveIdea).first()).toHaveText(/Approve/u); - await expect(by(page, ids.applyIdea).first()).toBeDisabled(); - - await approveIdea(reviewer); - await expect(by(page, ids.applyIdea).first()).toBeEnabled(); - } finally { - await reviewerContext.close(); - } -}); diff --git a/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx b/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx new file mode 100644 index 00000000..aa2efac1 --- /dev/null +++ b/apps/web/e2e/ideas/review/revoke-idea-approval.test.tsx @@ -0,0 +1,37 @@ +import { + arrange, + by, + ids, + openIdea, + openIdeaComparison, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('revoking the only approval prevents an idea from being applied', async () => { + const suffix = uniqueSuffix(); + const ideaName = `Approval reversal ${suffix}`; + const tripId = await arrange.createTrip({ name: `Review reversal ${suffix}` }); + const idea = await arrange.startIdea({ name: ideaName, tripId }); + await arrange.updateTrip({ dateNotes: 'More time by the river', tripId: idea.workingTripId }); + await arrange.requestIdeaReview(idea.proposalId); + const reviewer = await arrange.addMember('approval-reversal'); + const review = { ideaName, memberEmail: reviewer.email, tripId }; + + await openApp(); + await openIdea({ proposalId: idea.proposalId, tripId }); + await openIdeaComparison(); + await expect.element(by(ids.applyIdea).first()).toBeDisabled(); + + // The reviewer's own screen is covered by view tests; they act through the backend and the + // author's screen reacts live. + await arrange.approveIdea(review); + await expect.element(by(ids.applyIdea).first()).toBeEnabled(); + + await arrange.approveIdea({ ...review, approved: false }); + await expect.element(by(ids.applyIdea).first()).toBeDisabled(); + + await arrange.approveIdea(review); + await expect.element(by(ids.applyIdea).first()).toBeEnabled(); +}); diff --git a/apps/web/e2e/issues/create-an-issue.spec.ts b/apps/web/e2e/issues/create-an-issue.spec.ts deleted file mode 100644 index 12a51c3e..00000000 --- a/apps/web/e2e/issues/create-an-issue.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - by, - createIssue, - createPlaywrightActions, - createTrip, - filterIssuesByStatus, - ids, - issueRow, - openAppPath, - openIssueFromInbox, - openIssuesInbox, - openTripSection, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('creates a trip issue and opens it as a first-class page', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const suffix = uniqueSuffix(); - const tripName = `Issue trip ${suffix}`; - const issueTitle = `Rainy-day plan ${suffix}`; - const issueBody = 'Add a backup indoor day if it rains.'; - const app = createPlaywrightActions(); - - const tripId = await createTrip(page, { name: tripName }); - const issueId = await createIssue(page, { body: issueBody, title: issueTitle, tripId }); - const issueRecordId = await by(page, ids.issueHeading).getAttribute('data-issue-id'); - if (!issueRecordId) throw new Error('Created issue did not expose its record ID'); - - await app.addIssueComment(page, { content: `Need a museum option (${suffix})`, issueId }); - - await app.assignIssue(page, { assignee: { kind: 'agent' }, issueId }); - await expect(by(page, ids.issueToggleStatus)).toBeEnabled(); - - await app.setIssueStatus(page, { issueId, status: 'closed' }); - - await openIssuesInbox(page); - await filterIssuesByStatus(page, 'closed'); - const workspaceIssueLink = issueRow(page, issueTitle).locator('a').first(); - await expect(workspaceIssueLink).toHaveAttribute('href', `/issues/${issueId}`); - await openIssueFromInbox(page, issueTitle, { status: 'closed' }); - await expect(page).toHaveURL(new RegExp(`/issues/${issueId}`, 'u')); - await expect(by(page, ids.issueHeading)).toHaveAttribute('data-issue-id', issueRecordId); - - await openAppPath(page, `trips/${tripId}/issues`); - await expect(page).toHaveURL(new RegExp(`/trips/${tripId}/issues`, 'u')); - await openTripSection(page, 'issues'); - await filterIssuesByStatus(page, 'closed'); - await expect(issueRow(page, issueTitle).locator('a').first()).toHaveAttribute( - 'href', - `/issues/${issueId}` - ); -}); - -test('lists workspace issues from the sidebar', async ({ page }) => { - await signIn(page); - await openIssuesInbox(page); - await expect(by(page, ids.issuesTitle)).toBeVisible(); -}); diff --git a/apps/web/e2e/issues/create-an-issue.test.tsx b/apps/web/e2e/issues/create-an-issue.test.tsx new file mode 100644 index 00000000..94f16722 --- /dev/null +++ b/apps/web/e2e/issues/create-an-issue.test.tsx @@ -0,0 +1,60 @@ +import { + addIssueComment, + arrange, + assignIssue, + by, + createIssue, + expectPath, + filterIssuesByStatus, + ids, + issueRow, + openAppPath, + openIssueFromInbox, + openIssuesInbox, + openTrip, + openTripSection, + presentAttribute, + setIssueStatus, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('creates a trip issue and opens it as a first-class page', async () => { + const suffix = uniqueSuffix(); + const tripName = `Issue trip ${suffix}`; + const issueTitle = `Rainy-day plan ${suffix}`; + const issueBody = 'Add a backup indoor day if it rains.'; + + const { credentials } = await arrange.account(); + await openApp(); + const tripId = await openTrip(await arrange.createTrip({ name: tripName })); + const issueId = await createIssue({ body: issueBody, title: issueTitle }); + const issueRecordId = await presentAttribute(by(ids.issueHeading), 'data-issue-id'); + + await addIssueComment(`Need a museum option (${suffix})`); + + // Assigning a traveler keeps the flow independent of AI keys; Groam assignment is covered by + // the assignee picker's view tests and the backend's agent-run tests. + await assignIssue({ kind: 'user', name: credentials.name }); + await expect.element(by(ids.issueToggleStatus)).toBeEnabled(); + + await setIssueStatus('closed'); + + await openIssuesInbox(); + await filterIssuesByStatus('closed'); + await expect + .element(issueRow(issueTitle).getByRole('link').first()) + .toHaveAttribute('href', `/issues/${issueId}`); + await openIssueFromInbox(issueTitle, { status: 'closed' }); + await expectPath(new RegExp(`/issues/${issueId}`, 'u')); + await expect.element(by(ids.issueHeading)).toHaveAttribute('data-issue-id', issueRecordId); + + await openAppPath(`trips/${tripId}/issues`); + await expectPath(new RegExp(`/trips/${tripId}/issues`, 'u')); + await openTripSection('issues'); + await filterIssuesByStatus('closed'); + await expect + .element(issueRow(issueTitle).getByRole('link').first()) + .toHaveAttribute('href', `/issues/${issueId}`); +}); diff --git a/apps/web/e2e/issues/issue-comment-persistence.spec.ts b/apps/web/e2e/issues/issue-comment-persistence.spec.ts deleted file mode 100644 index a788d55c..00000000 --- a/apps/web/e2e/issues/issue-comment-persistence.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - by, - createIssue, - createPlaywrightActions, - createTrip, - ids, - openIssueFromInbox, - openIssueFromTrip, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('issue comments persist across trip and workspace views', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const suffix = uniqueSuffix(); - const title = `Issue conversation ${suffix}`; - const comment = `Check the museum schedule ${suffix}`; - const tripId = await createTrip(page, { name: `Comment trip ${suffix}` }); - const issueId = await createIssue(page, { body: 'Need an indoor alternative.', title, tripId }); - await createPlaywrightActions().addIssueComment(page, { content: comment, issueId }); - await expect(by(page, ids.issueComment)).toBeEmpty(); - - await openIssueFromTrip(page, tripId, title); - await expect(page.getByText(comment, { exact: true })).toBeVisible(); - await openIssueFromInbox(page, title); - await expect(page.getByText(comment, { exact: true })).toBeVisible(); -}); diff --git a/apps/web/e2e/issues/issue-comment-persistence.test.tsx b/apps/web/e2e/issues/issue-comment-persistence.test.tsx new file mode 100644 index 00000000..ec4771a8 --- /dev/null +++ b/apps/web/e2e/issues/issue-comment-persistence.test.tsx @@ -0,0 +1,30 @@ +import { + addIssueComment, + arrange, + by, + ids, + openIssue, + openIssueFromInbox, + openIssueFromTrip, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('issue comments persist across trip and workspace views', async () => { + const suffix = uniqueSuffix(); + const title = `Issue conversation ${suffix}`; + const comment = `Check the museum schedule ${suffix}`; + const tripId = await arrange.createTrip({ name: `Comment trip ${suffix}` }); + const issueId = await arrange.createIssue({ body: 'Need an indoor alternative.', title, tripId }); + await openApp(); + await openIssue(issueId); + await addIssueComment(comment); + await expect.element(by(ids.issueComment)).toHaveValue(''); + + await openIssueFromTrip(tripId, title); + await expect.element(page.getByText(comment)).toBeVisible(); + await openIssueFromInbox(title); + await expect.element(page.getByText(comment)).toBeVisible(); +}); diff --git a/apps/web/e2e/issues/reopen-issue.spec.ts b/apps/web/e2e/issues/reopen-issue.spec.ts deleted file mode 100644 index c8036a03..00000000 --- a/apps/web/e2e/issues/reopen-issue.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - by, - createIssue, - createTrip, - filterIssuesByStatus, - ids, - issueRow, - openIssueFromInbox, - openIssuesInbox, - setIssueStatus, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('reopening a closed issue moves it back into the open workspace filter', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - const title = `Reopen rainy-day plan ${suffix}`; - const tripId = await createTrip(page, { name: `Issue reopen trip ${suffix}` }); - const issueId = await createIssue(page, { body: 'Check indoor alternatives.', title, tripId }); - await setIssueStatus(page, { issueId, status: 'closed' }); - - await openIssuesInbox(page); - await filterIssuesByStatus(page, 'closed'); - await expect(issueRow(page, title)).toBeVisible(); - await openIssueFromInbox(page, title, { status: 'closed' }); - await setIssueStatus(page, { issueId, status: 'open' }); - await expect(by(page, ids.issueStatus)).toHaveAttribute('data-status', 'open'); - - await openIssuesInbox(page); - await filterIssuesByStatus(page, 'open'); - await expect(issueRow(page, title)).toBeVisible(); - await filterIssuesByStatus(page, 'closed'); - await expect(issueRow(page, title)).toHaveCount(0); -}); diff --git a/apps/web/e2e/issues/reopen-issue.test.tsx b/apps/web/e2e/issues/reopen-issue.test.tsx new file mode 100644 index 00000000..0d14b762 --- /dev/null +++ b/apps/web/e2e/issues/reopen-issue.test.tsx @@ -0,0 +1,38 @@ +import { + arrange, + by, + expectGone, + filterIssuesByStatus, + ids, + issueRow, + openIssue, + openIssueFromInbox, + openIssuesInbox, + setIssueStatus, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('reopening a closed issue moves it back into the open workspace filter', async () => { + const suffix = uniqueSuffix(); + const title = `Reopen rainy-day plan ${suffix}`; + const tripId = await arrange.createTrip({ name: `Issue reopen trip ${suffix}` }); + const issueId = await arrange.createIssue({ body: 'Check indoor alternatives.', title, tripId }); + await openApp(); + await openIssue(issueId); + await setIssueStatus('closed'); + + await openIssuesInbox(); + await filterIssuesByStatus('closed'); + await expect.element(issueRow(title)).toBeVisible(); + await openIssueFromInbox(title, { status: 'closed' }); + await setIssueStatus('open'); + await expect.element(by(ids.issueStatus)).toHaveAttribute('data-status', 'open'); + + await openIssuesInbox(); + await filterIssuesByStatus('open'); + await expect.element(issueRow(title)).toBeVisible(); + await filterIssuesByStatus('closed'); + await expectGone(issueRow(title)); +}); diff --git a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts deleted file mode 100644 index 5332432b..00000000 --- a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - acceptGroupInvitation, - applyIdea, - approveIdea, - by, - createIssue, - createTrip, - idFromPath, - ids, - inviteGroupMember, - openAppPath, - openIdeaFromList, - requestIdeaReview, - signIn, - startIdeaFromIssue, - uniqueSuffix, - uniqueTestUser, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('creates a trip, makes an issue, and resolves it with a merged idea', async ({ - browser, - page -}) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const member = uniqueTestUser('approver'); - - const tripId = await createTrip(page, { name: `Merge trip ${suffix}` }); - const issueId = await createIssue(page, { - body: 'If it rains we need a museum day.', - title: `Need indoor backup ${suffix}`, - tripId - }); - - const ideaName = await startIdeaFromIssue(page); - - await updateTrip(page, { dateNotes: 'Indoor museum day if it rains' }); - await requestIdeaReview(page); - - const invitationCode = await inviteGroupMember(page); - const memberContext = await browser.newContext(); - const memberPage = await memberContext.newPage(); - await acceptGroupInvitation(memberPage, invitationCode, member); - await openIdeaFromList(memberPage, ideaName); - await expect(memberPage).toHaveURL(/\/trips\/[^/]+\/ideas\/[^/]+/u, { timeout: 20_000 }); - await approveIdea(memberPage); - await memberPage.close(); - await memberContext.close(); - - await page.bringToFront(); - await openIdeaFromList(page, ideaName); - await applyIdea(page); - - await openAppPath(page, `issues/${issueId}`); - await expect(by(page, ids.issueStatus)).toHaveAttribute('data-status', 'closed'); - expect(idFromPath(page, 'issues')).toBe(issueId); -}); diff --git a/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx new file mode 100644 index 00000000..59718e54 --- /dev/null +++ b/apps/web/e2e/issues/resolve-an-issue-with-a-merged-idea.test.tsx @@ -0,0 +1,45 @@ +import { + applyIdea, + arrange, + by, + idFromPath, + ids, + openAppPath, + openIdeaFromList, + openIssue, + requestIdeaReview, + startIdeaFromIssue, + uniqueSuffix, + updateTrip +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('creates a trip, makes an issue, and resolves it with a merged idea', async () => { + const suffix = uniqueSuffix(); + const member = await arrange.addMember('approver'); + const tripId = await arrange.createTrip({ name: `Merge trip ${suffix}` }); + await openApp(); + const issueId = await openIssue( + await arrange.createIssue({ + body: 'If it rains we need a museum day.', + title: `Need indoor backup ${suffix}`, + tripId + }) + ); + + const ideaName = await startIdeaFromIssue(); + + await updateTrip({ dateNotes: 'Indoor museum day if it rains' }); + await requestIdeaReview(); + + // A group member approves; their own screens are covered by view tests, so they use the backend. + await arrange.approveIdea({ ideaName, memberEmail: member.email, tripId }); + + await openIdeaFromList({ name: ideaName }, 'view'); + await applyIdea(); + + await openAppPath(`issues/${issueId}`); + await expect.element(by(ids.issueStatus)).toHaveAttribute('data-status', 'closed'); + expect(idFromPath('issues')).toBe(issueId); +}); diff --git a/apps/web/e2e/trips/archive-trip-filter.spec.ts b/apps/web/e2e/trips/archive-trip-filter.spec.ts deleted file mode 100644 index 05030b60..00000000 --- a/apps/web/e2e/trips/archive-trip-filter.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - archiveTrip, - createTrip, - filterTripsByStatus, - openAppPath, - openTripFromList, - openTrips, - restoreTrip, - signIn, - tripCard, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('archived trips leave the active list and return after restoring', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const tripName = `Archive filter ${uniqueSuffix()}`; - await createTrip(page, { name: tripName }); - await archiveTrip(page); - - await openAppPath(page, 'trips'); - await filterTripsByStatus(page, 'active'); - await expect(tripCard(page, tripName)).toHaveCount(0); - await filterTripsByStatus(page, 'archived'); - await expect(tripCard(page, tripName)).toBeVisible(); - - await openTripFromList(page, tripName); - await restoreTrip(page); - await openTrips(page); - await filterTripsByStatus(page, 'active'); - await expect(tripCard(page, tripName)).toBeVisible(); -}); diff --git a/apps/web/e2e/trips/archive-trip-filter.test.tsx b/apps/web/e2e/trips/archive-trip-filter.test.tsx new file mode 100644 index 00000000..a281b09d --- /dev/null +++ b/apps/web/e2e/trips/archive-trip-filter.test.tsx @@ -0,0 +1,34 @@ +import { + archiveTrip, + arrange, + expectGone, + filterTripsByStatus, + openAppPath, + openTrip, + openTripFromList, + openTrips, + restoreTrip, + tripCard, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('archived trips leave the active list and return after restoring', async () => { + const tripName = `Archive filter ${uniqueSuffix()}`; + await openApp(); + await openTrip(await arrange.createTrip({ name: tripName })); + await archiveTrip(); + + await openAppPath('trips'); + await filterTripsByStatus('active'); + await expectGone(tripCard(tripName)); + await filterTripsByStatus('archived'); + await expect.element(tripCard(tripName)).toBeVisible(); + + await openTripFromList(tripName); + await restoreTrip(); + await openTrips(); + await filterTripsByStatus('active'); + await expect.element(tripCard(tripName)).toBeVisible(); +}); diff --git a/apps/web/e2e/trips/create-a-trip.spec.ts b/apps/web/e2e/trips/create-a-trip.spec.ts deleted file mode 100644 index e88298ec..00000000 --- a/apps/web/e2e/trips/create-a-trip.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - archiveTrip, - by, - createTrip, - ids, - openTrips, - restoreTrip, - signIn, - tripCard, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('creates, archives, restores, and lists a destination-optional trip', async ({ page }) => { - test.setTimeout(90_000); - await signIn(page); - const tripName = `Beach idea ${uniqueSuffix()}`; - - await createTrip(page, { - dateNotes: 'Late July', - durationDays: 8, - name: tripName - }); - await expect(by(page, ids.tripDestination)).toHaveAttribute( - 'data-destination-status', - 'undecided' - ); - await expect(by(page, ids.tripRole)).toHaveAttribute('data-role', 'organizer'); - - await archiveTrip(page); - await restoreTrip(page); - await expect(by(page, ids.tripArchive)).toBeVisible(); - - await openTrips(page); - await expect(tripCard(page, tripName)).toBeVisible(); -}); diff --git a/apps/web/e2e/trips/create-a-trip.test.tsx b/apps/web/e2e/trips/create-a-trip.test.tsx new file mode 100644 index 00000000..436fbd7a --- /dev/null +++ b/apps/web/e2e/trips/create-a-trip.test.tsx @@ -0,0 +1,30 @@ +import { + archiveTrip, + by, + createTripFromTrips, + ids, + openTrips, + restoreTrip, + tripCard, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('creates, archives, restores, and lists a destination-optional trip', async () => { + await openApp(); + const tripName = `Beach idea ${uniqueSuffix()}`; + + await createTripFromTrips({ dateNotes: 'Late July', durationDays: 8, name: tripName }, 'empty'); + await expect + .element(by(ids.tripDestination)) + .toHaveAttribute('data-destination-status', 'undecided'); + await expect.element(by(ids.tripRole)).toHaveAttribute('data-role', 'organizer'); + + await archiveTrip(); + await restoreTrip(); + await expect.element(by(ids.tripArchive)).toBeVisible(); + + await openTrips(); + await expect.element(tripCard(tripName)).toBeVisible(); +}); diff --git a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts b/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts deleted file mode 100644 index ae2ef815..00000000 --- a/apps/web/e2e/trips/resolve-trip-detail-conflicts.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - addIdea, - approveAndApplyIdea, - by, - createTrip, - ids, - openAppPath, - openIdeaComparison, - openTripSection, - reloadAppPage, - requestIdeaReview, - resolveTripDetailConflict, - signIn, - uniqueSuffix, - updateTrip -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async ({ - page -}) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix('detail-conflict'); - const initialName = `Original plan ${suffix}`; - const sharedName = `Updated shared plan ${suffix}`; - const ideaName = `details/mixed-resolution-${suffix}`; - const ideaTripName = `Idea plan ${suffix}`; - const ideaNotes = `Idea dates ${suffix}`; - - const tripId = await createTrip(page, { name: initialName }); - await addIdea(page, { name: ideaName }); - await updateTrip(page, { dateNotes: ideaNotes, name: ideaTripName }); - await openTripSection(page, 'itinerary'); - await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0); - await expect(page.getByText('The shared trip has changed since you started')).toHaveCount(0); - await requestIdeaReview(page); - const firstIdeaUrl = page.url(); - - await openAppPath(page, `trips/${tripId}/ideas`); - await addIdea(page, { name: `details/competing-${suffix}` }); - await updateTrip(page, { dateNotes: 'Shared dates', name: sharedName }); - await requestIdeaReview(page); - await approveAndApplyIdea(page); - - await openAppPath(page, firstIdeaUrl); - await approveAndApplyIdea(page); - await openIdeaComparison(page); - await expect( - page.getByRole('heading', { name: 'Trip details differ from the shared trip' }) - ).toBeVisible(); - await resolveTripDetailConflict(page, { - choices: [{ field: 'Trip name', source: 'shared' }], - expectedBranches: [{ field: 'Trip name', idea: ideaTripName, shared: sharedName }], - expectedPreview: [sharedName, ideaNotes], - expectedSides: [sharedName, ideaName] - }); - await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName); - await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0); - await reloadAppPage(page); - await expect(by(page, ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName); - await expect(by(page, ids.itineraryDetailsResolve)).toHaveCount(0); - await openIdeaComparison(page); - await expect(page.getByText('The shared trip has changed since you started')).toHaveCount(0); - await expect(page.getByText(ideaNotes, { exact: true })).toBeVisible(); -}); diff --git a/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx b/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx new file mode 100644 index 00000000..4163ec05 --- /dev/null +++ b/apps/web/e2e/trips/resolve-trip-detail-conflicts.test.tsx @@ -0,0 +1,75 @@ +import { + addIdea, + approveAndApplyIdea, + arrange, + by, + currentPath, + expectGone, + ids, + openAppPath, + openIdea, + openIdeaComparison, + openTrip, + openTripSection, + reloadAppPage, + requestIdeaReview, + resolveTripDetailConflict, + uniqueSuffix, + updateTrip +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('keeps ordinary edits quiet and resolves real competing ideas with mixed choices', async () => { + const suffix = uniqueSuffix('detail-conflict'); + const initialName = `Original plan ${suffix}`; + const sharedName = `Updated shared plan ${suffix}`; + const ideaName = `details/mixed-resolution-${suffix}`; + const ideaTripName = `Idea plan ${suffix}`; + const ideaNotes = `Idea dates ${suffix}`; + + const tripId = await arrange.createTrip({ name: initialName }); + await openApp(); + await openTrip(tripId); + await addIdea({ name: ideaName }); + await updateTrip({ dateNotes: ideaNotes, name: ideaTripName }); + await openTripSection('itinerary'); + await expect.element(page.getByRole('region', { name: 'Itinerary editor' })).toBeVisible(); + await expectGone(by(ids.itineraryDetailsResolve)); + await expectGone(page.getByText('The shared trip has changed since you started')); + await requestIdeaReview(); + const firstIdeaUrl = currentPath(); + + const competing = await arrange.startIdea({ name: `details/competing-${suffix}`, tripId }); + await arrange.updateTrip({ + dateNotes: 'Shared dates', + name: sharedName, + tripId: competing.workingTripId + }); + await arrange.requestIdeaReview(competing.proposalId); + await openIdea({ proposalId: competing.proposalId, tripId }); + await openIdeaComparison(); + await approveAndApplyIdea(); + + await openAppPath(firstIdeaUrl); + await approveAndApplyIdea(); + await openIdeaComparison(); + await expect + .element(page.getByRole('heading', { name: 'Trip details differ from the shared trip' })) + .toBeVisible(); + await resolveTripDetailConflict({ + choices: [{ field: 'Trip name', source: 'shared' }], + expectedBranches: [{ field: 'Trip name', idea: ideaTripName, shared: sharedName }], + expectedPreview: [sharedName, ideaNotes], + expectedSides: [sharedName, ideaName] + }); + await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName); + await expectGone(by(ids.itineraryDetailsResolve)); + await reloadAppPage(); + await expect.element(by(ids.tripHeading)).toHaveAttribute('data-trip-name', sharedName); + await expectGone(by(ids.itineraryDetailsResolve)); + await openIdeaComparison(); + await expectGone(page.getByText('The shared trip has changed since you started')); + await expect.element(page.getByText(ideaNotes)).toBeVisible(); +}); diff --git a/apps/web/e2e/trips/trip-search.spec.ts b/apps/web/e2e/trips/trip-search.spec.ts deleted file mode 100644 index 9a35126a..00000000 --- a/apps/web/e2e/trips/trip-search.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - createTrip, - openTrips, - searchTrips, - signIn, - tripCard, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('trip search only shows matching plans and clearing it restores the list', async ({ - page -}) => { - test.setTimeout(90_000); - await signIn(page); - const suffix = uniqueSuffix(); - const first = `Lisbon search ${suffix}`; - const second = `Porto search ${suffix}`; - await createTrip(page, { name: first }); - await createTrip(page, { name: second }); - await openTrips(page); - - await searchTrips(page, first); - await expect(tripCard(page, first)).toBeVisible(); - await expect(tripCard(page, second)).toHaveCount(0); - await searchTrips(page, ''); - await expect(tripCard(page, first)).toBeVisible(); - await expect(tripCard(page, second)).toBeVisible(); -}); diff --git a/apps/web/e2e/trips/trip-search.test.tsx b/apps/web/e2e/trips/trip-search.test.tsx new file mode 100644 index 00000000..2a409840 --- /dev/null +++ b/apps/web/e2e/trips/trip-search.test.tsx @@ -0,0 +1,25 @@ +import { + arrange, + expectGone, + searchTrips, + tripCard, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('trip search only shows matching plans and clearing it restores the list', async () => { + const suffix = uniqueSuffix(); + const first = `Lisbon search ${suffix}`; + const second = `Porto search ${suffix}`; + await arrange.createTrip({ name: first }); + await arrange.createTrip({ name: second }); + await openApp(); + + await searchTrips(first); + await expect.element(tripCard(first)).toBeVisible(); + await expectGone(tripCard(second)); + await searchTrips(''); + await expect.element(tripCard(first)).toBeVisible(); + await expect.element(tripCard(second)).toBeVisible(); +}); diff --git a/apps/web/e2e/workspace/browse-workspace.spec.ts b/apps/web/e2e/workspace/browse-workspace.spec.ts deleted file mode 100644 index 15b8584a..00000000 --- a/apps/web/e2e/workspace/browse-workspace.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - addIdea, - by, - createIssue, - createTrip, - expectHomeDashboard, - ids, - openAppPath, - openChatInbox, - openFirstMobileListRow, - openIssuesInbox, - openMobileChat, - openSettingsGroupSection, - openSharedTrip, - recoverFromNotFound, - selectSystemTheme, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('group people live in settings, not the sidebar', async ({ page }) => { - await signIn(page); - await expect(page.getByRole('link', { name: 'Group', exact: true })).toHaveCount(0); - await openSettingsGroupSection(page); - await expect(by(page, ids.inviteMember)).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Invitation codes' })).toBeVisible(); - - await openAppPath(page, 'group'); - await expect(page).toHaveURL(/\/settings\/group\/?$/u); - await expect(by(page, ids.inviteMember)).toBeVisible(); - - await openAppPath(page, 'group/members'); - await expect(page).toHaveURL(/\/settings\/group\/?$/u); -}); - -test('workspace routes and 404 recovery work', async ({ page }) => { - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); - page.on('console', (message) => { - if (message.type() === 'error') errors.push(message.text()); - }); - await signIn(page); - - await expect(by(page, ids.tripsTitle)).toBeVisible(); - await openIssuesInbox(page); - await openChatInbox(page); - - await openAppPath(page, 'does-not-exist'); - await recoverFromNotFound(page); - await expectHomeDashboard(page); - expect(errors).toEqual([]); -}); - -test('system theme and mobile navigation work', async ({ page }) => { - await page.emulateMedia({ colorScheme: 'dark' }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.addInitScript(() => localStorage.removeItem('groam-theme')); - await signIn(page); - await expect(page.locator('html')).toHaveClass(/dark/u); - - const navigation = by(page, ids.mobileNav); - await expect(navigation).toBeVisible(); - await openMobileChat(page); - await selectSystemTheme(page); -}); - -test('mobile idea and issue lists keep titles readable and rows navigable', async ({ page }) => { - test.setTimeout(180_000); - await signIn(page); - const suffix = uniqueSuffix(); - const tripId = await createTrip(page, { name: `Mobile lists ${suffix}` }); - await addIdea(page, { name: `Mobile idea ${suffix}` }); - await openSharedTrip(page); - await createIssue(page, { - body: 'Keep this issue title readable on a narrow screen.', - title: `Mobile issue ${suffix}`, - tripId - }); - await page.setViewportSize({ width: 390, height: 844 }); - - for (const area of ['ideas', 'issues'] as const) { - await openAppPath(page, area); - await openFirstMobileListRow(page, area); - await expect(page).toHaveURL(area === 'ideas' ? /\/ideas\/.+/u : /\/issues\/.+/u); - await expect(by(page, ids.mobileNav)).toBeVisible(); - } -}); diff --git a/apps/web/e2e/workspace/browse-workspace.test.tsx b/apps/web/e2e/workspace/browse-workspace.test.tsx new file mode 100644 index 00000000..2337beab --- /dev/null +++ b/apps/web/e2e/workspace/browse-workspace.test.tsx @@ -0,0 +1,45 @@ +import { + by, + expectGone, + expectHomeDashboard, + expectPath, + expectWorkspaceReady, + ids, + openAppPath, + openChatInbox, + openIssuesInbox, + openSettingsGroupSection, + recoverFromNotFound +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; +import { openApp } from '@/testing/open-app'; + +test('group people live in settings, not the sidebar', async () => { + await openApp(); + await expectWorkspaceReady(); + await expectGone(page.getByRole('link', { name: 'Group' })); + await openSettingsGroupSection(); + await expect.element(by(ids.inviteMember)).toBeVisible(); + await expect.element(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await expect.element(page.getByRole('heading', { name: 'Invitation codes' })).toBeVisible(); + + await openAppPath('group'); + await expectPath(/\/settings\/group\/?$/u); + await expect.element(by(ids.inviteMember)).toBeVisible(); + + await openAppPath('group/members'); + await expectPath(/\/settings\/group\/?$/u); +}); + +test('workspace routes and 404 recovery work', async () => { + await openApp(); + + await expect.element(by(ids.tripsTitle)).toBeVisible(); + await openIssuesInbox(); + await openChatInbox(); + + await openAppPath('does-not-exist'); + await recoverFromNotFound(); + await expectHomeDashboard(); +}); diff --git a/apps/web/e2e/workspace/workspace-idea-search.spec.ts b/apps/web/e2e/workspace/workspace-idea-search.spec.ts deleted file mode 100644 index 99bd8a2a..00000000 --- a/apps/web/e2e/workspace/workspace-idea-search.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - addIdea, - createTrip, - ideaRow, - openWorkspaceIdeas, - searchIdeas, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('workspace idea search finds the matching proposal and clears cleanly', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - const first = `Coast proposal ${suffix}`; - const second = `Museum proposal ${suffix}`; - await createTrip(page, { name: `Coast trip ${suffix}` }); - await addIdea(page, { name: first }); - await createTrip(page, { name: `City trip ${suffix}` }); - await addIdea(page, { name: second }); - await openWorkspaceIdeas(page); - - await searchIdeas(page, 'Museum proposal'); - await expect(ideaRow(page, second)).toBeVisible(); - await expect(ideaRow(page, first)).toHaveCount(0); - await searchIdeas(page, ''); - await expect(ideaRow(page, second)).toBeVisible(); - await expect(ideaRow(page, first)).toBeVisible(); -}); diff --git a/apps/web/e2e/workspace/workspace-idea-search.test.tsx b/apps/web/e2e/workspace/workspace-idea-search.test.tsx new file mode 100644 index 00000000..c87efff2 --- /dev/null +++ b/apps/web/e2e/workspace/workspace-idea-search.test.tsx @@ -0,0 +1,29 @@ +import { + arrange, + expectGone, + ideaRow, + openWorkspaceIdeas, + searchIdeas, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('workspace idea search finds the matching proposal and clears cleanly', async () => { + const suffix = uniqueSuffix(); + const first = `Coast proposal ${suffix}`; + const second = `Museum proposal ${suffix}`; + const coastTripId = await arrange.createTrip({ name: `Coast trip ${suffix}` }); + await arrange.startIdea({ name: first, tripId: coastTripId }); + const cityTripId = await arrange.createTrip({ name: `City trip ${suffix}` }); + await arrange.startIdea({ name: second, tripId: cityTripId }); + await openApp(); + await openWorkspaceIdeas(); + + await searchIdeas('Museum proposal'); + await expect.element(ideaRow({ title: second })).toBeVisible(); + await expectGone(ideaRow({ title: first })); + await searchIdeas(''); + await expect.element(ideaRow({ title: second })).toBeVisible(); + await expect.element(ideaRow({ title: first })).toBeVisible(); +}); diff --git a/apps/web/e2e/workspace/workspace-issue-search.spec.ts b/apps/web/e2e/workspace/workspace-issue-search.spec.ts deleted file mode 100644 index f6eeac71..00000000 --- a/apps/web/e2e/workspace/workspace-issue-search.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - createIssue, - createTrip, - issueRow, - openAppPath, - openIssuesInbox, - searchIssues, - signIn, - uniqueSuffix -} from '@groam/app-actions/playwright'; -import { expect, test } from '@playwright/test'; - -test('workspace issue search finds the matching issue and clears cleanly', async ({ page }) => { - test.setTimeout(120_000); - await signIn(page); - const suffix = uniqueSuffix(); - const first = `Museum issue ${suffix}`; - const second = `Ferry issue ${suffix}`; - const tripId = await createTrip(page, { name: `Issue search trip ${suffix}` }); - await createIssue(page, { body: 'Check opening hours.', title: first, tripId }); - await openAppPath(page, `trips/${tripId}/issues`); - await createIssue(page, { body: 'Check departure time.', title: second, tripId }); - await openIssuesInbox(page); - - await searchIssues(page, 'Ferry issue'); - await expect(issueRow(page, second)).toBeVisible(); - await expect(issueRow(page, first)).toHaveCount(0); - await searchIssues(page, ''); - await expect(issueRow(page, first)).toBeVisible(); - await expect(issueRow(page, second)).toBeVisible(); -}); diff --git a/apps/web/e2e/workspace/workspace-issue-search.test.tsx b/apps/web/e2e/workspace/workspace-issue-search.test.tsx new file mode 100644 index 00000000..04f73591 --- /dev/null +++ b/apps/web/e2e/workspace/workspace-issue-search.test.tsx @@ -0,0 +1,28 @@ +import { + arrange, + expectGone, + issueRow, + openIssuesInbox, + searchIssues, + uniqueSuffix +} from '@groam/app-actions/vitest'; +import { expect, test } from 'vite-plus/test'; +import { openApp } from '@/testing/open-app'; + +test('workspace issue search finds the matching issue and clears cleanly', async () => { + const suffix = uniqueSuffix(); + const first = `Museum issue ${suffix}`; + const second = `Ferry issue ${suffix}`; + const tripId = await arrange.createTrip({ name: `Issue search trip ${suffix}` }); + await arrange.createIssue({ body: 'Check opening hours.', title: first, tripId }); + await arrange.createIssue({ body: 'Check departure time.', title: second, tripId }); + await openApp(); + await openIssuesInbox(); + + await searchIssues('Ferry issue'); + await expect.element(issueRow(second)).toBeVisible(); + await expectGone(issueRow(first)); + await searchIssues(''); + await expect.element(issueRow(first)).toBeVisible(); + await expect.element(issueRow(second)).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 866fac29..d3fec742 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,17 +24,14 @@ "@groam/app-actions": "workspace:*", "@groam/typescript-config": "workspace:*", "@groam/vitest-config": "workspace:*", - "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/router-cli": "1.167.39", "@tanstack/router-plugin": "1.168.41", - "@testing-library/react": "16.3.3", "@types/qrcode": "^1.5.6", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-istanbul": "catalog:", - "jsdom": "30.1.1", "tailwindcss": "^4.2.2", "typescript": "~7.0.2", "vite": "catalog:", @@ -46,15 +43,11 @@ "scripts": { "build": "tsc -b && vp build", "dev": "vp dev --host 0.0.0.0", - "e2e": "env -u NO_COLOR -u FORCE_COLOR playwright test", - "e2e:headed": "env -u NO_COLOR -u FORCE_COLOR playwright test --headed", - "e2e:install": "playwright install chromium", - "e2e:local": "PLAYWRIGHT_BASE_URL=http://localhost:5173 env -u NO_COLOR -u FORCE_COLOR playwright test", - "e2e:local:headed": "PLAYWRIGHT_BASE_URL=http://localhost:5173 env -u NO_COLOR -u FORCE_COLOR playwright test --headed", + "journeys": "vp test run --project journeys", "lint": "vp check .", "preview": "vp preview --host 0.0.0.0", "routes:generate": "NODE_ENV=production tsr generate", - "test": "vp test run --coverage", + "test": "vp test run --coverage --project unit --project views", "typecheck": "tsc -b" }, "type": "module" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts deleted file mode 100644 index ecb63617..00000000 --- a/apps/web/playwright.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { env } from '@groam/env/playwright'; -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - expect: { timeout: env.isCI ? 45_000 : 10_000 }, - fullyParallel: false, - outputDir: 'test-results', - reporter: env.isCI ? [['github'], ['html', { open: 'never' }]] : 'list', - retries: env.isCI ? 2 : 0, - testDir: './e2e', - timeout: env.isCI ? 180_000 : 30_000, - use: { - ...devices['Desktop Chrome'], - baseURL: env.baseUrl, - screenshot: 'only-on-failure', - trace: env.isCI ? 'on-first-retry' : 'retain-on-failure' - }, - workers: 1 -}); diff --git a/apps/web/src/app-root.tsx b/apps/web/src/app-root.tsx new file mode 100644 index 00000000..ee0633c5 --- /dev/null +++ b/apps/web/src/app-root.tsx @@ -0,0 +1,18 @@ +import { GroamAuthProvider } from '@groam/auth/provider'; +import { UiThemeProvider } from '@groam/ui/components/theme-provider'; +import type { ConvexReactClient } from 'convex/react'; +import { StrictMode } from 'react'; +import App from './app.tsx'; + +/** The whole app as users get it; journeys mount the same tree. */ +export function AppRoot({ client }: { client: ConvexReactClient }) { + return ( + + + + + + + + ); +} diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 49ae7ba4..e24194a5 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,7 +1,9 @@ import { Toaster } from '@groam/ui/components/sonner'; import { Spinner } from '@groam/ui/components/spinner'; import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react'; -import { lazy, Suspense } from 'react'; +import { Activity, lazy, Suspense } from 'react'; +import { useSessionChange } from '@/features/auth/hooks/use-session-change'; +import { SessionChangeContext } from '@/features/auth/session-change-context'; const AuthScreen = lazy(() => import('@/features/auth/auth-screen').then(({ AuthScreen }) => ({ @@ -26,15 +28,22 @@ function LoadingSession() { } export default function App() { + const { changeSession, isChangingSession } = useSessionChange(); + return ( - <> + - }> - - + {/* Hidden, the router keeps its state in case the session survives (a wrong code); once + Convex holds a new session it remounts. */} + {isChangingSession && } + + }> + + + }> @@ -42,6 +51,6 @@ export default function App() { - + ); } diff --git a/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx b/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx index a4cb2765..305ba427 100644 --- a/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx +++ b/apps/web/src/features/agents/agent-detail/agent-detail-view.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import type { AgentRun } from '@/features/agents/hooks/use-agent'; import { testIds } from '@/lib/test-ids'; import { AgentDetailView } from './agent-detail-view'; @@ -45,9 +46,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({ useStartQueuedAssignRuns: () => undefined })); -vi.mock('@tanstack/react-router', async () => { +vi.mock('@tanstack/react-router', async (importOriginal) => { const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock'); - return createAgentTestRouterMock(vi.fn(), { preventDefault: true }); + return { + ...(await importOriginal()), + ...createAgentTestRouterMock(vi.fn(), { preventDefault: true }) + }; }); beforeEach(() => { @@ -57,10 +61,8 @@ beforeEach(() => { state.status = 'Exhausted'; }); -afterEach(cleanup); - describe('AgentDetailView', () => { - test('puts the back link above the title hero and status badges', () => { + test('puts the back link above the title hero and status badges', async () => { state.agent = { activeRunCount: 1, assignedIssueCount: 1, @@ -71,18 +73,20 @@ describe('AgentDetailView', () => { status: 'working', surface: 'standalone' }; - render(); + await render(); - const back = screen.getByRole('link', { name: 'All agents' }); - const title = screen.getByTestId('agent-detail-title'); - expect(back.getAttribute('href')).toBe('/agents'); - expect(back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(title.textContent).toContain('Issue agent'); - expect(screen.getByText('Works assigned issues')).toBeTruthy(); - expect(screen.getByText('Worker')).toBeTruthy(); + const back = page.getByRole('link', { name: 'All agents' }); + const title = page.getByTestId('agent-detail-title'); + await expect.element(back).toHaveAttribute('href', '/agents'); + expect( + back.element().compareDocumentPosition(title.element()) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(title.element().textContent).toContain('Issue agent'); + await expect.element(page.getByText('Works assigned issues')).toBeInTheDocument(); + await expect.element(page.getByText('Worker')).toBeInTheDocument(); }); - test('lists this agent’s runs as links to the run page, not a split detail pane', () => { + test('lists this agent’s runs as links to the run page, not a split detail pane', async () => { state.agent = { activeRunCount: 0, assignedIssueCount: 2, @@ -147,16 +151,18 @@ describe('AgentDetailView', () => { updatedAt: 1 } ]; - render(); - expect(screen.getByRole('heading', { name: 'Runs' })).toBeTruthy(); - expect(screen.getByRole('link', { name: /Rainy-day option/u }).getAttribute('href')).toBe( - '/agents/issue/run-rain' - ); - expect(screen.getByRole('link', { name: /Coast day/u }).getAttribute('href')).toBe( - '/agents/issue/run-coast' + await render(); + await expect.element(page.getByRole('heading', { name: 'Runs' })).toBeInTheDocument(); + await expect + .element(page.getByRole('link', { name: /Rainy-day option/u })) + .toHaveAttribute('href', '/agents/issue/run-rain'); + await expect + .element(page.getByRole('link', { name: /Coast day/u })) + .toHaveAttribute('href', '/agents/issue/run-coast'); + await expect.element(page.getByTestId(testIds.agentRunDetail)).not.toBeInTheDocument(); + await expect.element(page.getByText('Assigned issues')).not.toBeInTheDocument(); + expect(page.getByTestId(testIds.agentsRoster).element().className).not.toMatch( + /max-h-\[50%\]/u ); - expect(screen.queryByTestId(testIds.agentRunDetail)).toBeNull(); - expect(screen.queryByText('Assigned issues')).toBeNull(); - expect(screen.getByTestId(testIds.agentsRoster).className).not.toMatch(/max-h-\[50%\]/u); }); }); diff --git a/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx b/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx index 06d4abfe..7306dbbf 100644 --- a/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx +++ b/apps/web/src/features/agents/agent-list/agents-list-view.test.tsx @@ -1,5 +1,7 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { useViewport } from '@groam/vitest-config/viewport'; +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import type { WorkspaceAgentRun } from '@/features/agents/hooks/use-workspace-agent-runs'; import { testIds } from '@/lib/test-ids'; import { AgentsListView } from './agents-list-view'; @@ -36,9 +38,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({ const navigate = vi.hoisted(() => vi.fn()); -vi.mock('@tanstack/react-router', async () => { +vi.mock('@tanstack/react-router', async (importOriginal) => { const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock'); - return createAgentTestRouterMock(navigate); + return { + ...(await importOriginal()), + ...createAgentTestRouterMock(navigate) + }; }); function run( @@ -79,24 +84,26 @@ beforeEach(() => { state.status = 'Exhausted'; }); -afterEach(cleanup); - describe('AgentsListView', () => { - test('shows a labeled loading state while runs load', () => { + test('shows a labeled loading state while runs load', async () => { state.isLoading = true; - render(); - expect(screen.getByRole('status', { name: 'Loading agent runs…' })).toBeTruthy(); + await render(); + await expect + .element(page.getByRole('status', { name: 'Loading agent runs…' })) + .toBeInTheDocument(); }); - test('shows an empty state when the group has no runs', () => { - render(); - expect(screen.getByText('Nothing running yet')).toBeTruthy(); - expect(screen.queryByText('Latest run from each agent.')).toBeNull(); - expect(screen.queryByTestId(testIds.agentsRoster)).toBeNull(); - expect(screen.getByRole('heading', { name: 'Background activity' })).toBeTruthy(); + test('shows an empty state when the group has no runs', async () => { + await render(); + await expect.element(page.getByText('Nothing running yet')).toBeInTheDocument(); + await expect.element(page.getByText('Latest run from each agent.')).not.toBeInTheDocument(); + await expect.element(page.getByTestId(testIds.agentsRoster)).not.toBeInTheDocument(); + await expect + .element(page.getByRole('heading', { name: 'Background activity' })) + .toBeInTheDocument(); }); - test('lists every run sequentially instead of one latest row per agent', () => { + test('lists every run sequentially instead of one latest row per agent', async () => { state.runs = [ run({ agentId: 'issue', @@ -135,45 +142,49 @@ describe('AgentsListView', () => { updatedAt: Date.now() - 10 * 60_000 }) ]; - render(); - expect(screen.getByRole('heading', { name: 'Background activity' })).toBeTruthy(); - expect( - screen.getByText( - 'Work Groam is doing for your group. Continue planning in chat while it runs.' + await render(); + await expect + .element(page.getByRole('heading', { name: 'Background activity' })) + .toBeInTheDocument(); + await expect + .element( + page.getByText( + 'Work Groam is doing for your group. Continue planning in chat while it runs.' + ) ) - ).toBeTruthy(); - const roster = screen.getByTestId(testIds.agentsRoster); - expect(roster).toBeTruthy(); - expect(roster.className).not.toContain('dashboard-lift-card'); - expect(roster.className).not.toContain('rounded-2xl'); - expect(roster.className).toContain('bg-card'); - const tableContainer = roster.querySelector('[data-slot="table-container"]'); - expect(tableContainer?.getAttribute('data-variant')).toBe('list'); - expect(roster.querySelector('table')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Run' })).toBeTruthy(); - expect(screen.getByText('Agent')).toBeTruthy(); - expect(screen.getByText('Called by')).toBeTruthy(); - expect(screen.getByText('Status')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Updated' })).toBeTruthy(); - expect(screen.getAllByTestId(testIds.agentCard)).toHaveLength(4); - expect(screen.getByRole('link', { name: /Rainy-day option/u }).getAttribute('href')).toBe( - '/agents/issue/run-issue-new' - ); - expect(screen.getByRole('link', { name: /Packing help/u }).getAttribute('href')).toBe( - '/agents/groam/run-chat' - ); - expect(screen.getAllByText(/Issue agent/u)).toHaveLength(2); - expect(screen.getByText('Groam', { exact: true })).toBeTruthy(); - expect(screen.getByText(/Idea reviewer/u)).toBeTruthy(); - expect(screen.getByText('Packing help')).toBeTruthy(); - expect(screen.getByText('Rainy-day option')).toBeTruthy(); - expect(screen.getByText('Coast day')).toBeTruthy(); - expect(screen.getByText('Kyoto food crawl')).toBeTruthy(); - expect(screen.getAllByText('Running')).toHaveLength(2); - expect(screen.getByText('Failed')).toBeTruthy(); - expect(screen.getByText('Done')).toBeTruthy(); - expect(screen.getByText(/The model timed out/u)).toBeTruthy(); - const runCells = screen.getAllByTestId(testIds.agentRunFeedRunCell); + .toBeInTheDocument(); + const roster = page.getByTestId(testIds.agentsRoster); + await expect.element(roster).not.toHaveClass('dashboard-lift-card'); + await expect.element(roster).not.toHaveClass('rounded-2xl'); + await expect.element(roster).toHaveClass('bg-card'); + expect( + roster.element().querySelector('[data-slot="table-container"]')?.getAttribute('data-variant') + ).toBe('list'); + await expect.element(roster.getByRole('table')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Run' })).toBeInTheDocument(); + await expect.element(page.getByText('Agent')).toBeInTheDocument(); + await expect.element(page.getByText('Called by')).toBeInTheDocument(); + await expect.element(page.getByText('Status')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Updated' })).toBeInTheDocument(); + expect(page.getByTestId(testIds.agentCard).elements()).toHaveLength(4); + await expect + .element(page.getByRole('link', { name: /Rainy-day option/u })) + .toHaveAttribute('href', '/agents/issue/run-issue-new'); + await expect + .element(page.getByRole('link', { name: /Packing help/u })) + .toHaveAttribute('href', '/agents/groam/run-chat'); + expect(page.getByText(/Issue agent/u).elements()).toHaveLength(2); + await expect.element(page.getByText('Groam')).toBeInTheDocument(); + await expect.element(page.getByText(/Idea reviewer/u)).toBeInTheDocument(); + await expect.element(page.getByText('Packing help')).toBeInTheDocument(); + await expect.element(page.getByText('Rainy-day option')).toBeInTheDocument(); + await expect.element(page.getByText('Coast day')).toBeInTheDocument(); + await expect.element(page.getByText('Kyoto food crawl')).toBeInTheDocument(); + expect(page.getByText('Running').elements()).toHaveLength(2); + await expect.element(page.getByText('Failed')).toBeInTheDocument(); + await expect.element(page.getByText('Done')).toBeInTheDocument(); + await expect.element(page.getByText(/The model timed out/u)).toBeInTheDocument(); + const runCells = page.getByTestId(testIds.agentRunFeedRunCell).elements(); expect(runCells).toHaveLength(4); for (const cell of runCells) { expect(cell.className).toContain('min-w-0'); @@ -181,19 +192,19 @@ describe('AgentsListView', () => { expect(title?.className).toContain('truncate'); expect(title?.className).toContain('font-semibold'); } - const rosterRow = screen.getAllByTestId(testIds.agentCard)[0]; + const rosterRow = page.getByTestId(testIds.agentCard).elements()[0]; expect(rosterRow?.tagName).toBe('TR'); expect(rosterRow?.className).toContain('cursor-pointer'); - expect(screen.queryByText('Nothing running yet')).toBeNull(); - expect(screen.queryByText('Idle')).toBeNull(); - expect(screen.queryByText('Latest run from each agent.')).toBeNull(); - expect(screen.queryByText('Latest run')).toBeNull(); - const retry = screen.getByRole('button', { name: 'Retry from zero' }); + await expect.element(page.getByText('Nothing running yet')).not.toBeInTheDocument(); + await expect.element(page.getByText('Idle')).not.toBeInTheDocument(); + await expect.element(page.getByText('Latest run from each agent.')).not.toBeInTheDocument(); + await expect.element(page.getByText('Latest run')).not.toBeInTheDocument(); + const retry = page.getByRole('button', { name: 'Retry from zero' }).element(); expect(retry.textContent).toBe(''); - expect(screen.queryByText('Retry from zero')).toBeNull(); + await expect.element(page.getByText('Retry from zero')).not.toBeInTheDocument(); }); - test('loads the next page of runs', () => { + test('loads the next page of runs', async () => { state.status = 'CanLoadMore'; state.runs = [ run({ @@ -201,12 +212,12 @@ describe('AgentsListView', () => { title: 'First page run' }) ]; - render(); - fireEvent.click(screen.getByTestId(testIds.agentsLoadMore)); + await render(); + await page.getByTestId(testIds.agentsLoadMore).click(); expect(state.loadMore).toHaveBeenCalledWith(25); }); - test('filters the loaded runs without hiding the table chrome', () => { + test('filters the loaded runs without hiding the table chrome', async () => { state.runs = [ run({ id: 'run-rain' as WorkspaceAgentRun['id'], @@ -219,11 +230,56 @@ describe('AgentsListView', () => { title: 'Packing help' }) ]; - render(); - fireEvent.change(screen.getByTestId(testIds.agentsRunsFilter), { - target: { value: 'packing' } + await render(); + await page.getByTestId(testIds.agentsRunsFilter).fill('packing'); + await expect.element(page.getByText('Packing help')).toBeInTheDocument(); + await expect.element(page.getByText('Rainy-day option')).not.toBeInTheDocument(); + }); + + describe.each([ + ['desktop', 1280], + ['phone', 390] + ])('layout on a %s', (_device, width) => { + const fits = (element: Element) => { + const box = element.getBoundingClientRect(); + return box.left >= 0 && box.right <= document.documentElement.clientWidth; + }; + + test('puts the title above its description and the empty state below, inside the page', async () => { + await useViewport(width, 844); + await render(); + const title = page.getByTestId(testIds.agentsTitle); + const description = page.getByText(/^Work Groam is doing for your group/u); + const empty = page.getByText('Nothing running yet'); + await expect.element(empty).toBeVisible(); + const titleBox = title.element().getBoundingClientRect(); + const descriptionBox = description.element().getBoundingClientRect(); + const emptyBox = empty.element().getBoundingClientRect(); + expect(titleBox.bottom).toBeLessThanOrEqual(descriptionBox.top); + expect(descriptionBox.bottom).toBeLessThanOrEqual(emptyBox.top); + for (const element of [title, description, empty]) expect(fits(element.element())).toBe(true); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth + ); + }); + + test('keeps a populated run table inside the page', async () => { + await useViewport(width, 844); + state.runs = [ + run({ + id: 'run-long' as WorkspaceAgentRun['id'], + title: 'A very long run title that has to truncate instead of pushing the page sideways' + }) + ]; + await render(); + // On a phone the wide table scrolls inside its own frame instead of the page. + await expect + .element(page.getByRole('link', { name: /A very long run title/u })) + .toBeInTheDocument(); + expect(fits(page.getByTestId(testIds.agentsRoster).element())).toBe(true); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth + ); }); - expect(screen.getByText('Packing help')).toBeTruthy(); - expect(screen.queryByText('Rainy-day option')).toBeNull(); }); }); diff --git a/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx index 2026fee2..15995e11 100644 --- a/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx +++ b/apps/web/src/features/agents/agent-run/agent-run-log.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, test } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { describe, expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import type { AgentRunEvent } from '@/features/agents/hooks/use-agent-run'; import { testIds } from '@/lib/test-ids'; import { AgentRunLog } from './agent-run-log'; @@ -43,52 +44,54 @@ const events: AgentRunEvent[] = [ } ]; -afterEach(cleanup); - describe('AgentRunLog', () => { - test('filters tool calls and failures without changing event order', () => { - render(); - expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(3); - fireEvent.click(screen.getByRole('button', { name: 'Tool calls' })); - expect(screen.queryByText('Started review')).toBeNull(); - expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(2); - fireEvent.click(screen.getByRole('button', { name: 'Errors' })); - expect(screen.getByRole('button', { name: 'Errors' }).getAttribute('aria-pressed')).toBe( - 'true' + test('filters tool calls and failures without changing event order', async () => { + await render(); + expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(3); + await page.getByRole('button', { name: 'Tool calls' }).click(); + await expect.element(page.getByText('Started review')).not.toBeInTheDocument(); + expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(2); + await page.getByRole('button', { name: 'Errors' }).click(); + await expect + .element(page.getByRole('button', { name: 'Errors' })) + .toHaveAttribute('aria-pressed', 'true'); + expect(page.getByTestId(testIds.agentEvent).elements()).toHaveLength(1); + await expect.element(page.getByText('Connection unavailable')).toBeInTheDocument(); + await page.getByRole('button', { name: 'All activity' }).click(); + expect(page.getByTestId(testIds.agentEvent).elements()[0].textContent).toContain( + 'Started review' ); - expect(screen.getAllByTestId(testIds.agentEvent)).toHaveLength(1); - expect(screen.getByText('Connection unavailable')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'All activity' })); - expect(screen.getAllByTestId(testIds.agentEvent)[0].textContent).toContain('Started review'); }); - test('distinguishes loading activity from an empty completed run', () => { - const { rerender } = render(); - expect(screen.getByRole('status', { name: 'Loading activity' })).toBeTruthy(); - expect(screen.queryByText('No activity recorded')).toBeNull(); - rerender(); - expect(screen.getByText('No activity recorded')).toBeTruthy(); - expect(screen.queryByRole('status')).toBeNull(); + test('distinguishes loading activity from an empty completed run', async () => { + const { rerender } = await render(); + await expect + .element(page.getByRole('status', { name: 'Loading activity' })) + .toBeInTheDocument(); + await expect.element(page.getByText('No activity recorded')).not.toBeInTheDocument(); + await rerender(); + await expect.element(page.getByText('No activity recorded')).toBeInTheDocument(); + await expect.element(page.getByRole('status')).not.toBeInTheDocument(); }); - test('explains the live waiting state', () => { - render(); - expect(screen.getByText('Waiting for activity')).toBeTruthy(); - expect(screen.getByText('Updates automatically')).toBeTruthy(); + test('explains the live waiting state', async () => { + await render(); + await expect.element(page.getByText('Waiting for activity')).toBeInTheDocument(); + await expect.element(page.getByText('Updates automatically')).toBeInTheDocument(); }); - test('keeps pagination available when a filter has no matches', () => { - render( + test('keeps pagination available when a filter has no matches', async () => { + await render( Load earlier} /> ); - fireEvent.click(screen.getByRole('button', { name: 'Errors' })); - expect(screen.getByText('No matching activity')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Load earlier' })).toBeTruthy(); + await page.getByRole('button', { name: 'Errors' }).click(); + await expect.element(page.getByText('No matching activity')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeInTheDocument(); }); - test('labels tool inputs and outputs inside a collapsed disclosure', () => { - render(); - const disclosure = screen.getByText('Tool payload').closest('details'); + test('labels tool inputs and outputs inside a collapsed disclosure', async () => { + await render(); + const disclosure = page.getByText('Tool payload').element().closest('details'); expect(disclosure?.open).toBe(false); expect(disclosure?.textContent).toContain('Input'); expect(disclosure?.textContent).toContain('Output'); diff --git a/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx index 40ec4d2c..7867ad74 100644 --- a/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx +++ b/apps/web/src/features/agents/agent-run/agent-run-report.test.tsx @@ -1,15 +1,14 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, test } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { describe, expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import { testIds } from '@/lib/test-ids'; import { AgentRunReport } from './agent-run-report'; -afterEach(cleanup); - const activityId = 'jh7abcdefghijklmnopqrstuvwxyz014'; describe('AgentRunReport', () => { - test('renders structured cards instead of a raw pre-wrap dump', () => { - render( + test('renders structured cards instead of a raw pre-wrap dump', async () => { + await render( ); - expect(screen.getByTestId(testIds.agentRunReport)).toBeTruthy(); - expect(screen.getByText('On the draft')).toBeTruthy(); - expect(screen.getByText('Not applied')).toBeTruthy(); - expect(screen.getByText(/rainy-day museum/u)).toBeTruthy(); - expect(screen.getByText('€24')).toBeTruthy(); - expect(screen.getByText('Could not add the funicular')).toBeTruthy(); - expect(screen.getAllByTestId(testIds.agentRunReportSection)).toHaveLength(2); + await expect.element(page.getByTestId(testIds.agentRunReport)).toBeInTheDocument(); + await expect.element(page.getByText('On the draft')).toBeInTheDocument(); + await expect.element(page.getByText('Not applied')).toBeInTheDocument(); + await expect.element(page.getByText(/rainy-day museum/u)).toBeInTheDocument(); + await expect.element(page.getByText('€24')).toBeInTheDocument(); + await expect.element(page.getByText('Could not add the funicular')).toBeInTheDocument(); + expect(page.getByTestId(testIds.agentRunReportSection).elements()).toHaveLength(2); expect(document.querySelector('p.whitespace-pre-wrap')).toBeNull(); }); }); diff --git a/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx index d4b4d57e..6f94487b 100644 --- a/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx +++ b/apps/web/src/features/agents/agent-run/agent-run-table.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, expect, test, vi } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { expect, test, vi } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/browser'; import type { WorkspaceAgentRun } from '@/features/agents/hooks/use-workspace-agent-runs'; import { testIds } from '@/lib/test-ids'; import { AgentRunTable } from './agent-run-table'; @@ -11,13 +12,14 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({ useAgentRunControls: () => ({ retry, start: vi.fn(), stop: vi.fn() }) })); -vi.mock('@tanstack/react-router', async () => { +vi.mock('@tanstack/react-router', async (importOriginal) => { const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock'); - return createAgentTestRouterMock(navigate); + return { + ...(await importOriginal()), + ...createAgentTestRouterMock(navigate) + }; }); -afterEach(cleanup); - const longError = 'Groam AI is temporarily unavailable. Check your provider settings and try again in a few minutes.'; @@ -51,32 +53,32 @@ function run(overrides: Partial = {}): WorkspaceAgentRun { }; } -test('renders run, agent, caller, status, and updated columns', () => { - render(); - const row = screen.getByTestId(testIds.agentCard); +test('renders run, agent, caller, status, and updated columns', async () => { + await render(); + const row = page.getByTestId(testIds.agentCard).element(); expect(row.tagName).toBe('TR'); - expect(screen.getByText('Idea reviewer')).toBeTruthy(); - expect(screen.getByText('Alex')).toBeTruthy(); - expect(screen.getByText('Failed')).toBeTruthy(); + await expect.element(page.getByText('Idea reviewer')).toBeInTheDocument(); + await expect.element(page.getByText('Alex')).toBeInTheDocument(); + await expect.element(page.getByText('Failed')).toBeInTheDocument(); - const cell = screen.getByTestId(testIds.agentRunFeedRunCell); + const cell = page.getByTestId(testIds.agentRunFeedRunCell).element(); expect(cell.className).toContain('min-w-0'); - const title = screen.getByRole('link', { name: /Kyoto food crawl/u }); + const title = page.getByRole('link', { name: /Kyoto food crawl/u }).element(); expect(title.className).toContain('truncate'); expect(title.className).toContain('font-semibold'); expect(title.getAttribute('href')).toBe('/agents/reviewer/run-failed'); - const detail = screen.getByText(/Groam AI is temporarily unavailable/u); + const detail = page.getByText(/Groam AI is temporarily unavailable/u).element(); expect(detail.className).toContain('truncate'); expect(detail.className).toContain('text-xs'); - expect(screen.queryByTestId(testIds.agentRetryFromZero)).toBeNull(); + await expect.element(page.getByTestId(testIds.agentRetryFromZero)).not.toBeInTheDocument(); }); -test('retries from zero without navigating away', () => { - render(); - const retryButton = screen.getByTestId(testIds.agentRetryFromZero); +test('retries from zero without navigating away', async () => { + await render(); + const retryButton = page.getByTestId(testIds.agentRetryFromZero).element(); expect(retryButton.textContent).toBe(''); - fireEvent.click(retryButton); + await userEvent.click(retryButton); expect(retry).toHaveBeenCalledWith({ runId: 'run-failed' }); expect(navigate).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx b/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx index 74c64a4d..37e3fcbb 100644 --- a/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx +++ b/apps/web/src/features/agents/agent-run/agent-run-view.test.tsx @@ -1,7 +1,8 @@ import { agentRunEventLabels } from '@groam/ai/backend/runs/events'; import { agentRunDuration, agentRunTime } from '@groam/ai/backend/runs/time'; -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { render } from '@groam/vitest-config/react'; +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import type { AgentRun } from '@/features/agents/hooks/use-agent'; import { testIds } from '@/lib/test-ids'; import { AgentRunView } from './agent-run-view'; @@ -67,9 +68,12 @@ vi.mock('@/features/agents/hooks/use-agent-run', () => ({ useStartQueuedAssignRuns: () => undefined })); -vi.mock('@tanstack/react-router', async () => { +vi.mock('@tanstack/react-router', async (importOriginal) => { const { createAgentTestRouterMock } = await import('@/features/agents/agent-test-router-mock'); - return createAgentTestRouterMock(navigate, { preventDefault: true }); + return { + ...(await importOriginal()), + ...createAgentTestRouterMock(navigate, { preventDefault: true }) + }; }); function issueAgent() { @@ -122,10 +126,8 @@ beforeEach(() => { state.runs = []; }); -afterEach(cleanup); - describe('AgentRunView', () => { - test('shows the run log for chat Groam instead of sending people to the thread', () => { + test('shows the run log for chat Groam instead of sending people to the thread', async () => { state.agent = { activeRunCount: 1, assignedIssueCount: 0, @@ -164,41 +166,44 @@ describe('AgentRunView', () => { tripId: null }); state.runs = [state.run]; - render(); - expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Packing help'); - expect(screen.getByText('Open chat')).toBeTruthy(); - expect(screen.getByTestId(testIds.agentRunContext).textContent).toContain('Lea'); - expect(screen.getByRole('link', { name: 'Packing help' }).getAttribute('href')).toBe( - '/chat/discussion-1' - ); - expect(screen.getByText('Timeline')).toBeTruthy(); - expect(screen.queryByText('Worker')).toBeNull(); - expect(screen.queryByText(/transcript in the discussion/u)).toBeNull(); - expect(screen.getByText('Report')).toBeTruthy(); - expect(screen.getByText('Bring one universal adapter.')).toBeTruthy(); - expect(screen.getByTestId('agent-event').textContent).toContain('Responding'); - expect(screen.getAllByText('Running').length).toBe(1); - expect(screen.queryByText('Assigned issues')).toBeNull(); + await render(); + expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain('Packing help'); + await expect.element(page.getByText('Open chat')).toBeInTheDocument(); + expect(page.getByTestId(testIds.agentRunContext).element().textContent).toContain('Lea'); + await expect + .element(page.getByRole('link', { name: 'Packing help' })) + .toHaveAttribute('href', '/chat/discussion-1'); + await expect.element(page.getByText('Timeline')).toBeInTheDocument(); + await expect.element(page.getByText('Worker')).not.toBeInTheDocument(); + await expect.element(page.getByText(/transcript in the discussion/u)).not.toBeInTheDocument(); + await expect.element(page.getByText('Report')).toBeInTheDocument(); + await expect.element(page.getByText('Bring one universal adapter.')).toBeInTheDocument(); + expect(page.getByTestId('agent-event').element().textContent).toContain('Responding'); + expect(page.getByText('Running').elements().length).toBe(1); + await expect.element(page.getByText('Assigned issues')).not.toBeInTheDocument(); }); - test('keeps All agents above the run title and links the agent as a crumb', () => { + test('keeps All agents above the run title and links the agent as a crumb', async () => { state.agent = issueAgent(); state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' }); state.runs = [state.run]; - render(); + await render(); - const back = screen.getByRole('link', { name: 'All agents' }); - const crumb = screen.getByRole('link', { name: 'Issue agent' }); - const title = screen.getByTestId(testIds.agentRunTitle); - expect(back.getAttribute('href')).toBe('/agents'); - expect(crumb.getAttribute('href')).toBe('/agents/issue'); - expect(back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(title.textContent).toContain('Rainy-day option'); - expect(screen.queryByText('Worker')).toBeNull(); - expect(screen.queryByText('Needs attention')).toBeNull(); + const back = page.getByRole('link', { name: 'All agents' }); + const title = page.getByTestId(testIds.agentRunTitle); + await expect.element(back).toHaveAttribute('href', '/agents'); + await expect + .element(page.getByRole('link', { name: 'Issue agent' })) + .toHaveAttribute('href', '/agents/issue'); + expect( + back.element().compareDocumentPosition(title.element()) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(title.element().textContent).toContain('Rainy-day option'); + await expect.element(page.getByText('Worker')).not.toBeInTheDocument(); + await expect.element(page.getByText('Needs attention')).not.toBeInTheDocument(); }); - test('shows this run’s log without issue or trip metadata clutter', () => { + test('shows this run’s log without issue or trip metadata clutter', async () => { state.agent = issueAgent(); state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' }); state.runs = [state.run]; @@ -215,28 +220,28 @@ describe('AgentRunView', () => { toolName: null } ]; - render(); - expect(screen.queryByText('Worker')).toBeNull(); - expect(screen.getAllByText('Rainy-day option').length).toBeGreaterThan(0); - expect(screen.getByText('Report')).toBeTruthy(); - expect(screen.getByText('Opened a draft idea.')).toBeTruthy(); - const started = screen.getByTestId(testIds.agentEvent); - expect(started.textContent).toContain('Started working on'); - expect(started.textContent).toContain('Rainy-day option'); - expect(screen.queryByText('Started working on this issue')).toBeNull(); - expect( - within(started).getByRole('link', { name: 'Rainy-day option' }).getAttribute('href') - ).toBe('/issues/issue-1'); - expect(screen.getByText('Called by')).toBeTruthy(); - const context = screen.getByTestId(testIds.agentRunContext); + await render(); + await expect.element(page.getByText('Worker')).not.toBeInTheDocument(); + expect(page.getByText('Rainy-day option').elements().length).toBeGreaterThan(0); + await expect.element(page.getByText('Report')).toBeInTheDocument(); + await expect.element(page.getByText('Opened a draft idea.')).toBeInTheDocument(); + const started = page.getByTestId(testIds.agentEvent); + expect(started.element().textContent).toContain('Started working on'); + expect(started.element().textContent).toContain('Rainy-day option'); + await expect.element(page.getByText('Started working on this issue')).not.toBeInTheDocument(); + await expect + .element(started.getByRole('link', { name: 'Rainy-day option' })) + .toHaveAttribute('href', '/issues/issue-1'); + await expect.element(page.getByText('Called by')).toBeInTheDocument(); + const context = page.getByTestId(testIds.agentRunContext).element(); expect(context.textContent).not.toContain('Issue'); expect(context.textContent).not.toContain('Trip'); expect(context.textContent).not.toMatch(/\bIdea\b/u); - expect(screen.queryByRole('link', { name: 'Lisbon' })).toBeNull(); - expect(screen.getByText('Timeline')).toBeTruthy(); + await expect.element(page.getByRole('link', { name: 'Lisbon' })).not.toBeInTheDocument(); + await expect.element(page.getByText('Timeline')).toBeInTheDocument(); }); - test('links the issue title on the started event from related data when detail is missing', () => { + test('links the issue title on the started event from related data when detail is missing', async () => { state.agent = issueAgent(); state.run = run({ id: 'run-issue' as AgentRun['id'], title: 'Rainy-day option' }); state.runs = [state.run]; @@ -253,15 +258,15 @@ describe('AgentRunView', () => { toolName: null } ]; - render(); - const started = screen.getByTestId(testIds.agentEvent); - expect(started.textContent).toContain('Started working on'); - expect( - within(started).getByRole('link', { name: 'Rainy-day option' }).getAttribute('href') - ).toBe('/issues/issue-1'); + await render(); + const started = page.getByTestId(testIds.agentEvent); + expect(started.element().textContent).toContain('Started working on'); + await expect + .element(started.getByRole('link', { name: 'Rainy-day option' })) + .toHaveAttribute('href', '/issues/issue-1'); }); - test('shows only this run’s title, not other activities', () => { + test('shows only this run’s title, not other activities', async () => { state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' }; const rain = run({ completedAt: 2, @@ -287,14 +292,16 @@ describe('AgentRunView', () => { }); state.run = rain; state.runs = [rain, coast]; - render(); - expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Rainy-day option'); - expect(screen.queryByTestId(testIds.agentOtherRuns)).toBeNull(); - expect(screen.queryByRole('link', { name: /Coast day/u })).toBeNull(); - expect(screen.queryByText('Coast day')).toBeNull(); + await render(); + expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain( + 'Rainy-day option' + ); + await expect.element(page.getByTestId(testIds.agentOtherRuns)).not.toBeInTheDocument(); + await expect.element(page.getByRole('link', { name: /Coast day/u })).not.toBeInTheDocument(); + await expect.element(page.getByText('Coast day')).not.toBeInTheDocument(); }); - test('links the related idea without inventing a generated-idea event', () => { + test('links the related idea without inventing a generated-idea event', async () => { state.agent = { activeRunCount: 0, assignedIssueCount: 0, @@ -352,34 +359,36 @@ describe('AgentRunView', () => { toolName: null } ]; - render(); - expect(screen.getByText('Idea reviewer')).toBeTruthy(); - expect(screen.queryByTestId(testIds.agentAssignedIssues)).toBeNull(); - expect(screen.getByText('Called by')).toBeTruthy(); - const idea = screen.getByTestId(testIds.agentRunIdea); + await render(); + await expect.element(page.getByText('Idea reviewer')).toBeInTheDocument(); + await expect.element(page.getByTestId(testIds.agentAssignedIssues)).not.toBeInTheDocument(); + await expect.element(page.getByText('Called by')).toBeInTheDocument(); + const idea = page.getByTestId(testIds.agentRunIdea).element(); expect(idea.textContent).toContain('Kyoto food crawl'); - expect(screen.getByRole('link', { name: 'Open idea' }).getAttribute('href')).toContain( + expect(page.getByRole('link', { name: 'Open idea' }).element().getAttribute('href')).toContain( '/trips/trip-1/ideas/proposal-1/' ); - const events = screen.getAllByTestId(testIds.agentEvent); + const events = page.getByTestId(testIds.agentEvent).elements(); expect(events).toHaveLength(3); expect(events.some((event) => event.textContent?.includes('Thought'))).toBe(true); expect(events.some((event) => event.textContent?.includes('Check whether day 3'))).toBe(true); expect(events.some((event) => event.textContent?.includes('Read itinerary'))).toBe(true); expect(events.some((event) => event.textContent?.includes('Started review'))).toBe(true); - expect(screen.getByRole('button', { name: 'Thoughts' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Thoughts' })); - const thoughts = screen.getAllByTestId(testIds.agentEvent); + await expect.element(page.getByRole('button', { name: 'Thoughts' })).toBeInTheDocument(); + await page.getByRole('button', { name: 'Thoughts' }).click(); + const thoughts = page.getByTestId(testIds.agentEvent).elements(); expect(thoughts).toHaveLength(1); expect(thoughts[0]?.textContent).toContain('Check whether day 3'); expect(idea.textContent).not.toContain('generated'); - expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Kyoto food crawl'); - const context = screen.getByTestId(testIds.agentRunContext); + expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain( + 'Kyoto food crawl' + ); + const context = page.getByTestId(testIds.agentRunContext).element(); expect(context.textContent).not.toMatch(/\bIdea\b/u); expect(context.textContent).not.toContain('Trip'); }); - test('omits the idea callout when the run has no proposal', () => { + test('omits the idea callout when the run has no proposal', async () => { state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' }; state.run = run({ completedAt: 1, @@ -393,12 +402,14 @@ describe('AgentRunView', () => { updatedAt: 1 }); state.runs = [state.run]; - render(); - expect(screen.queryByTestId(testIds.agentRunIdea)).toBeNull(); - expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Other activity'); + await render(); + await expect.element(page.getByTestId(testIds.agentRunIdea)).not.toBeInTheDocument(); + expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain( + 'Other activity' + ); }); - test('shows run duration, timestamps, tool payload, and load earlier', () => { + test('shows run duration, timestamps, tool payload, and load earlier', async () => { const startedAt = 1_000; const completedAt = 9_000; state.agent = { @@ -431,21 +442,25 @@ describe('AgentRunView', () => { toolName: 'getItinerary' } ]; - render(); - expect(screen.getAllByText('Done').length).toBe(1); - expect(screen.getByText('Called by')).toBeTruthy(); - expect(screen.getByText('Lea')).toBeTruthy(); - expect(screen.getByText('Timeline')).toBeTruthy(); - expect( - screen.getByText(new RegExp(`${agentRunDuration(startedAt, completedAt)}`, 'u')) - ).toBeTruthy(); - expect(screen.getByTestId('agent-event').textContent).toContain(agentRunTime(startedAt)); - expect(screen.getByTestId('agent-event').textContent).toContain('getItinerary'); - expect(screen.getByTestId('agent-event').textContent).toContain('"tripId": "trip-1"'); - expect(screen.getByTestId('agent-event').textContent).toContain('"tripName": "Lisbon"'); - expect(screen.getByRole('button', { name: 'Load earlier' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: 'Retry from zero' })).toBeNull(); - expect(screen.getByRole('button', { name: 'Re-run' })).toBeTruthy(); + await render(); + expect(page.getByText('Done').elements().length).toBe(1); + await expect.element(page.getByText('Called by')).toBeInTheDocument(); + await expect.element(page.getByText('Lea')).toBeInTheDocument(); + await expect.element(page.getByText('Timeline')).toBeInTheDocument(); + await expect + .element(page.getByText(new RegExp(`${agentRunDuration(startedAt, completedAt)}`, 'u'))) + .toBeInTheDocument(); + expect(page.getByTestId('agent-event').element().textContent).toContain( + agentRunTime(startedAt) + ); + expect(page.getByTestId('agent-event').element().textContent).toContain('getItinerary'); + expect(page.getByTestId('agent-event').element().textContent).toContain('"tripId": "trip-1"'); + expect(page.getByTestId('agent-event').element().textContent).toContain('"tripName": "Lisbon"'); + await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Retry from zero' })) + .not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Re-run' })).toBeInTheDocument(); }); test('re-runs a finished issue agent and opens the new run', async () => { @@ -460,8 +475,8 @@ describe('AgentRunView', () => { updatedAt: 2 }); state.runs = [state.run]; - render(); - fireEvent.click(screen.getByTestId(testIds.agentRerun)); + await render(); + await page.getByTestId(testIds.agentRerun).click(); expect(retry).toHaveBeenCalledWith({ runId: 'run-done' }); await vi.waitFor(() => { expect(navigate).toHaveBeenCalledWith({ @@ -471,7 +486,7 @@ describe('AgentRunView', () => { }); }); - test('hides Finished while queued and disables load earlier while fetching', () => { + test('hides Finished while queued and disables load earlier while fetching', async () => { state.agent = issueAgent(); state.eventsStatus = 'LoadingMore'; state.run = run({ @@ -482,12 +497,10 @@ describe('AgentRunView', () => { title: 'Rainy-day option' }); state.runs = [state.run]; - render(); - expect(screen.getByText('Waiting to start')).toBeTruthy(); - expect(screen.queryByText('Finished')).toBeNull(); - expect(screen.getByRole('button', { name: 'Load earlier' }).hasAttribute('disabled')).toBe( - true - ); + await render(); + await expect.element(page.getByText('Waiting to start')).toBeInTheDocument(); + await expect.element(page.getByText('Finished')).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Load earlier' })).toBeDisabled(); }); test('retries from zero and opens the new run', async () => { @@ -504,8 +517,8 @@ describe('AgentRunView', () => { updatedAt: 2 }); state.runs = [state.run]; - render(); - fireEvent.click(screen.getByTestId(testIds.agentRetryFromZero)); + await render(); + await page.getByTestId(testIds.agentRetryFromZero).click(); expect(retry).toHaveBeenCalledWith({ runId: 'run-failed' }); await vi.waitFor(() => { expect(navigate).toHaveBeenCalledWith({ @@ -515,7 +528,7 @@ describe('AgentRunView', () => { }); }); - test('does not lock a competing activity pane on the run destination', () => { + test('does not lock a competing activity pane on the run destination', async () => { state.agent = { ...issueAgent(), activeRunCount: 0, status: 'idle' }; state.run = run({ completedAt: 1, @@ -538,16 +551,18 @@ describe('AgentRunView', () => { updatedAt: index + 1 }) ); - render(); - expect(screen.getByTestId(testIds.agentDetailWorkspace).className).not.toMatch( - /overflow-hidden/u - ); - expect(screen.getByTestId(testIds.agentRunDetail).className).not.toMatch(/overflow-hidden/u); - expect(screen.queryByTestId(testIds.agentOtherRuns)).toBeNull(); - expect(screen.queryByText('Activity 1')).toBeNull(); + await render(); + await expect + .element(page.getByTestId(testIds.agentDetailWorkspace)) + .not.toHaveClass('overflow-hidden'); + await expect + .element(page.getByTestId(testIds.agentRunDetail)) + .not.toHaveClass('overflow-hidden'); + await expect.element(page.getByTestId(testIds.agentOtherRuns)).not.toBeInTheDocument(); + await expect.element(page.getByText('Activity 1')).not.toBeInTheDocument(); }); - test('shows the run title and full error without truncating in the destination', () => { + test('shows the run title and full error without truncating in the destination', async () => { const firstError = 'The model timed out while writing the rainy-day option.'; state.agent = { ...issueAgent(), activeRunCount: 0, lastActivityAt: 2, status: 'failed' }; state.run = run({ @@ -575,18 +590,22 @@ describe('AgentRunView', () => { } ]; state.runs = [state.run]; - render(); - const detail = screen.getByTestId(testIds.agentRunDetail); - expect(screen.getByTestId(testIds.agentRunTitle).textContent).toContain('Rainy-day option'); - expect(within(detail).getByText(firstError).className).toMatch(/whitespace-pre-wrap/u); - expect(within(detail).getAllByText(firstError)).toHaveLength(1); - expect(within(screen.getByTestId(testIds.agentEvent)).getByText('Run failed')).toBeTruthy(); - expect(screen.queryByText('Needs attention')).toBeNull(); - expect(screen.queryByText('Worker')).toBeNull(); + await render(); + const detail = page.getByTestId(testIds.agentRunDetail); + expect(page.getByTestId(testIds.agentRunTitle).element().textContent).toContain( + 'Rainy-day option' + ); + await expect.element(detail.getByText(firstError)).toHaveClass('whitespace-pre-wrap'); + expect(detail.getByText(firstError).elements()).toHaveLength(1); + await expect + .element(page.getByTestId(testIds.agentEvent).getByText('Run failed')) + .toBeInTheDocument(); + await expect.element(page.getByText('Needs attention')).not.toBeInTheDocument(); + await expect.element(page.getByText('Worker')).not.toBeInTheDocument(); }); }); -test('explains provider authentication failures without offering an unsupported reviewer retry', () => { +test('explains provider authentication failures without offering an unsupported reviewer retry', async () => { state.agent = { ...issueAgent(), id: 'reviewer', label: 'Idea reviewer' }; state.run = run({ agentId: 'reviewer', @@ -597,13 +616,15 @@ test('explains provider authentication failures without offering an unsupported report: null, title: 'Review the draft' }); - render(); - expect(screen.getByText(/organization or deployment admin/u)).toBeTruthy(); - expect(screen.queryByRole('button', { name: 'Retry from zero' })).toBeNull(); - expect(screen.getByText('No activity recorded')).toBeTruthy(); + await render(); + await expect.element(page.getByText(/organization or deployment admin/u)).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Retry from zero' })) + .not.toBeInTheDocument(); + await expect.element(page.getByText('No activity recorded')).toBeInTheDocument(); }); -test('does not present a last update as an unrecorded start or finish', () => { +test('does not present a last update as an unrecorded start or finish', async () => { state.agent = issueAgent(); state.run = run({ id: 'missing-times' as AgentRun['id'], @@ -612,19 +633,18 @@ test('does not present a last update as an unrecorded start or finish', () => { status: 'failed', title: 'Review the draft' }); - render(); - const context = screen.getByTestId(testIds.agentRunContext); - expect(within(context).getAllByText('Not recorded')).toHaveLength(2); - expect(within(context).queryByText('Duration')).toBeNull(); + await render(); + const context = page.getByTestId(testIds.agentRunContext); + await expect.poll(() => context.getByText('Not recorded').elements()).toHaveLength(2); + await expect.element(context.getByText('Duration')).not.toBeInTheDocument(); }); test('copies the run identifier for support and investigation', async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); state.agent = issueAgent(); state.run = run({ id: 'copy-this-run' as AgentRun['id'], title: 'Review the draft' }); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Copy run ID' })); + await render(); + await page.getByRole('button', { name: 'Copy run ID' }).click(); await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('copy-this-run')); }); @@ -636,15 +656,14 @@ test('prevents duplicate stop requests and shows pending feedback', async () => stop.mockReturnValue(pending); state.agent = issueAgent(); state.run = run({ id: 'stop-this-run' as AgentRun['id'], title: 'Review the draft' }); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Stop' })); - const stopping = screen.getByRole('button', { name: 'Stopping…' }); - expect(stopping.hasAttribute('disabled')).toBe(true); - fireEvent.click(stopping); + await render(); + await page.getByRole('button', { name: 'Stop' }).click(); + const stopping = page.getByRole('button', { name: 'Stopping…' }); + await expect.element(stopping).toBeDisabled(); + // Forced: a disabled control must ignore clicks, which is the behavior under test. + await stopping.click({ force: true }); expect(stop).toHaveBeenCalledTimes(1); expect(stop).toHaveBeenCalledWith({ runId: 'stop-this-run' }); finishStop(); - await vi.waitFor(() => - expect(screen.getByRole('button', { name: 'Stop' }).hasAttribute('disabled')).toBe(false) - ); + await expect.element(page.getByRole('button', { name: 'Stop' })).toBeEnabled(); }); diff --git a/apps/web/src/features/agents/agents-sidebar-nav.test.tsx b/apps/web/src/features/agents/agents-sidebar-nav.test.tsx index 8d2bad91..6273a245 100644 --- a/apps/web/src/features/agents/agents-sidebar-nav.test.tsx +++ b/apps/web/src/features/agents/agents-sidebar-nav.test.tsx @@ -1,8 +1,8 @@ import { SidebarProvider } from '@groam/ui/components/sidebar'; -import { cleanup, render, screen } from '@testing-library/react'; +import { render } from '@groam/vitest-config/react'; import type { ReactNode } from 'react'; -import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test'; -import { stubMatchMedia } from '@/testing/stub-match-media'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser'; import { AgentsSidebarNav } from './agents-sidebar-nav'; const state = vi.hoisted(() => ({ @@ -10,7 +10,8 @@ const state = vi.hoisted(() => ({ pathname: '/agents' })); -vi.mock('@tanstack/react-router', () => ({ +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, useLocation: () => ({ pathname: state.pathname }) })); @@ -19,7 +20,7 @@ vi.mock('./hooks/use-agent-roster', () => ({ useAgentRoster: () => ({ agents: state.agents }) })); -function renderNav() { +async function renderNav() { return render(