Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .cursor/rules/compare-player-takeaways.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
description: Compare-step player refs. Use when LimitedMediaPlayer, TeamCheckReference, or MediaPlayer end-of-play resets, or a second resource fails to play (TT-7005).
globs: web/**/LimitedMediaPlayer.tsx,web/**/MediaPlayer.tsx,web/**/TeamCheckReference.tsx,web/**/TeamCheckReference.test.tsx,web/**/LimitedMediaPlayer.test.tsx
alwaysApply: false
---

# Compare-step player

On the Compare step, picking a second resource does not play it and the player blinks. Blocking the dropdown while `itemPlaying` (PR #606) hid the bug until playback finished and the control unlocked. A red test that only saw `disabled: true` on a mocked child locked in that wrong behaviour.

Prove red and green with `.cursor/rules/tdd-takeaways.mdc`.

## Root cause

`LimitedMediaPlayer.resetPlay()` cleared `durationRef` and left `valueTracker` and `stop` holding the previous resource. `timeUpdate`'s end heuristic is guarded by `valueTracker.current !== 0`, so after anything had played that guard stayed true.

- `TeamCheckReference` renders `LimitedMediaPlayer` on every resource change, so its refs survive. `PassageDetailArtifacts` renders `{playItem !== '' && <LimitedMediaPlayer …>}`, which destroys the refs. Only Compare showed the bug.
- On a new blob, `WSAudioPlayer` calls `setDuration(0); setProgress(0)` before decode, and `setProgress` forwards to `onProgress`. With `durationRef === 0` and a stale `valueTracker`, `timeUpdate(0)` reads as end-of-media and fires `onEnded` on load.
- `onEnded` → `handleEnded` cleared `playItem` and bumped `resetCount`, whose effect re-selected the stored resource 500 ms later and re-armed `PassageDetailContext`'s 2-second auto-play timer. Reload → spurious `ended()` → remount loop.

## Fix

Clear end-detection refs when the source changes. `resetMediaTiming()` clears `stop` and `durationRef` and calls `resetPlay()` (`valueTracker`, position, slider), matching `MediaPlayer.resetWaveSurferTiming`. Require `durationRef.current > 0` before the duration-based end test. `handleEnded` leaves the `resetCount` clear/restore cycle unused. `ended()` calls only `resetPlay()`.

`resetPlay()` rewinds one playthrough. `resetMediaTiming()` also clears media-scoped timing and runs only from the `[srcMediaId]` effect. Clearing `stop` and `durationRef` inside `resetPlay()` removed the end boundary for a replay of the same loaded resource, because that replay never reports `onDuration` again. Regression: play to the end, replay with no second `onDuration`, and assert `onEnded` fires again.

## Tests

`LimitedMediaPlayer.test.tsx` mocks `HiddenPlayer` and captures `onProgress`, `onDuration`, and `setPlaying`. Replay production order: play to end → switch `srcMediaId` → `onDuration(0)`, `onProgress(0)`. Pair `expect(onEnded).not.toHaveBeenCalled()` with a follow-up assertion that the new source still ends at its own end.

React 19 function components receive `undefined` as the second call argument. Read `mock.calls[0][0]` for props. `jsdom` `localStorage` is a Proxy: seed with `localStorage.setItem` and `localStorage.clear()` in `beforeEach`.

Files: [LimitedMediaPlayer.tsx](../../web/src/components/LimitedMediaPlayer.tsx), [LimitedMediaPlayer.test.tsx](../../web/src/components/LimitedMediaPlayer.test.tsx), [TeamCheckReference.tsx](../../web/src/components/PassageDetail/TeamCheckReference.tsx), [TeamCheckReference.test.tsx](../../web/src/components/PassageDetail/TeamCheckReference.test.tsx), [MediaPlayer.tsx](../../web/src/components/MediaPlayer.tsx).
30 changes: 30 additions & 0 deletions .cursor/rules/cypress-component-patterns.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
description: Cypress component mount patterns. Use when writing or fixing *.cy.tsx specs, cy.mount providers, Orbit mock memory, PlanTabSelect, or WorkflowStepsMobile.
globs: web/**/*.cy.tsx,web/**/*.cy.ts
alwaysApply: false
---

# Cypress component patterns

Run commands and worktree setup: `.cursor/rules/cypress-testing-takeaways.mdc`.

- Build the provider stack: `GlobalProvider`, Redux store, `OrbitContext`, and feature contexts (`PassageDetailContext`, `UnsavedContext`). Prefer that over stubbing ESM imports.
- `useOrbitData` reads `memory.cache.query((q) => q.findRecords(type))` and uses `cache.liveQuery` only for change notifications. Serve both from one `findRecords`. A live-query mock that returns rows while `cache.query` returns `[]` is a false green.
- Drive `useStepPermissions` with mock records and relationships.
- Localization selectors need `LocalizedStrings` in the matching `strings` slice keys.
- Use stable DOM selectors (ids, `data-cy`). Scope list and table assertions to the region under test. MUI checkboxes and the feature list are both `.MuiListItem-root`; a project name in the checkbox list satisfies an unscoped text query.
- Navigation that uses `usePassageNavigate` needs `MemoryRouter` + `Routes`, with `UnsavedContext.checkSavedFn` invoking its callback immediately. `PlanTabSelect` and `PlanBar` need that same provider.
- `nextPasId` / `prevPasId` come from `section.relationships.passages` and memory records. Empty relationships mean no neighbors.
- `WorkflowStepsMobile` renders SVG stages. Assert `svg` / `svg g` or wrapper labels. Keep `workflow` / `currentstep` defaults when overriding. Set the viewport and dispatch `resize` after mount so the width-driven step list is computed.
- Busy or recording state: assert `setCurrentStep` calls and snack messages.
- Pagination needs enough steps and a small viewport to expose `prev` / `next`.

```ts
const mockStringsReducer = () => ({
loaded: true,
lang: 'en',
passageDetailStepComplete: new LocalizedStrings({
en: { title: 'Complete' },
}),
});
```
138 changes: 27 additions & 111 deletions .cursor/rules/cypress-testing-takeaways.mdc
Original file line number Diff line number Diff line change
@@ -1,141 +1,57 @@
---
description: Cypress component and e2e test takeaways for this repo
description: Run Cypress from web/. Worktree installs, Cypress cache, and spec commands for this repo.
alwaysApply: true
---

# Cypress Testing Takeaways
# Cypress

## Config and setup
Component and e2e tests use `web/cypress/config/local.config.ts` (`cy:open-*` / `cy:run-*` in `web/package.json`). `local.config.ts` merges `baseConfig` with e2e (`baseUrl`, Auth0 `env`) and component (`specPattern`, Vite). Keep both sections.

Both **component** and **e2e** tests use `web/cypress/config/local.config.ts`
(`cy:open-*` / `cy:run-*` in `web/package.json`).
`cy:run-ct` already passes `--browser chrome`. Pass only `--spec`.

- **`base.config.ts`** — retries, viewport, `e2e.setupNodeEvents`.
- **`local.config.ts`** — merges `baseConfig` with **e2e** (`baseUrl`, Auth0 `env`) and
**component** (`specPattern`, `@cypress/vite-dev-server` + test Vite overrides).
Keep both sections; do not drop `component` when changing e2e env.
Component mount patterns (providers, Orbit memory, mobile workflow): `.cursor/rules/cypress-component-patterns.mdc`.

Before testing locally, run `npm run devs` from `web`. That copies
`env-config` templates (including `VITE_AUTH_CACHE=localstorage` and
`VITE_TEST_EMAIL1` / `VITE_TEST_PW1`) into `web/.env.*`. Cypress reads
those files plus `web/src/auth/auth0-variables.json` for `cy.loginByAuth0()`.
## Worktree

## Running tests
Worktrees under `C:\Users\gtrih\git\wt` omit gitignored installs. The main checkout is `C:\Users\gtrih\git\apm-vite`.

From `web` (required — not the repo root):
When `web/node_modules` is missing and `web/package.json` matches that checkout:

```powershell
# Component tests (Cypress starts its own Vite dev server)
npm run cy:run-ct
npm run cy:open-ct
npm run cy:run-ct -- --spec=**/PlanTabSelect.cy.tsx --browser chrome
# PowerShell also accepts an unquoted glob without '=':
# npm run cy:run-ct -- --spec **/PlanTabSelect.cy.tsx

# Multiple specs: quote the whole --spec value and comma-separate globs.
# Do NOT append a second --browser (the cy:run-ct script already sets it);
# a duplicate flag can abort the run before any test executes.
npm run cy:run-ct -- --spec "**/PlanBar.cy.tsx,**/PlanTabSelect.cy.tsx"

# E2e (start the app first: npm start in another terminal)
npm run cy:run-local
npm run cy:open-local
cmd /c mklink /J "<worktree>\web\node_modules" "C:\Users\gtrih\git\apm-vite\web\node_modules"
```

### Cursor agents: Cypress binary / cache (do not reinstall)
Copy these from the main checkout when the worktree lacks them:

Agents often fail with:
- `web/src/auth/auth0-variables.json`
- `web/.env.local`
- `web/.env.development.local`

```text
No version of Cypress is installed in: ...\cursor-sandbox-cache\...\cypress\...\Cypress
Please reinstall Cypress by running: cypress install
`npm run devs` copies `env-config/.auth0-variables.*.json` and `env-config/.env.*.local`. Those templates are gitignored and live in the main checkout, so run `devs` there or copy its outputs.

When `web/src/buildDate.json` is missing, from the repo root:

```powershell
node env-config/writeDate.cjs
```

That is **not** a missing install. Cursor's shell can set `CYPRESS_CACHE_FOLDER` (and
sometimes `npm_config_devdir`) to an **empty sandbox temp cache**, so Cypress looks
there instead of the real user cache (`%LOCALAPPDATA%\Cypress\Cache` on Windows).
## Run

**Do not** run `cypress install` / reinstall to “fix” this. Clear the sandbox override
and run from `web`:
From `web`:

```powershell
Remove-Item Env:CYPRESS_CACHE_FOLDER -ErrorAction SilentlyContinue
Remove-Item Env:npm_config_devdir -ErrorAction SilentlyContinue
# Optional: pin the real cache explicitly
$env:CYPRESS_CACHE_FOLDER = "$env:LOCALAPPDATA\Cypress\Cache"
Set-Location <repo>\src
npm run cy:run-ct -- --spec **/YourSpec.cy.tsx
npm run cy:run-ct -- --spec=**/YourSpec.cy.tsx
```

If the user's own terminal already runs CT successfully, prefer that same cwd and
command shape; the agent failure is almost always the redirected cache, not the
project setup. Confirm with `$env:CYPRESS_CACHE_FOLDER` — if it contains
`cursor-sandbox-cache`, clear it.

Docker CT uses the same config file. E2e needs the app at `http://localhost:3000`
and a reachable dev API (`VITE_HOST`).
Quote a comma-separated list for several specs: `npm run cy:run-ct -- --spec "**/PlanBar.cy.tsx,**/PlanTabSelect.cy.tsx"`.

To narrow the Docker run to specific specs, set `CYPRESS_SPEC` (comma-separated
globs); unset runs the full `specPattern`. The `cypress` service command appends
`--spec` only when the var is non-empty:
`npm run cy:open-ct` opens the component runner. E2e (`npm run cy:run-local`, `npm run cy:open-local`) needs the app at `http://localhost:3000` and a reachable `VITE_HOST`. `cy.loginByAuth0()` visits `/access/online-cloud` and caches the session when `VITE_AUTH_CACHE=localstorage`.

```powershell
# PowerShell
$env:CYPRESS_SPEC="**/PlanBar.cy.tsx,**/PlanTabSelect.cy.tsx"; npm run cy:docker
$env:CYPRESS_SPEC=$null # clear to run everything again
```
A `CYPRESS_CACHE_FOLDER` value containing `cursor-sandbox-cache` means the shell is pointed at an empty cache. Clear it and set the user cache as above. Leave `cypress install` unused.

```bash
# bash/zsh
CYPRESS_SPEC="**/PlanBar.cy.tsx,**/PlanTabSelect.cy.tsx" npm run cy:docker
```
Docker uses the same config. Set `CYPRESS_SPEC` to a comma-separated glob list, then `npm run cy:docker`. Clear `CYPRESS_SPEC` to run the full `specPattern`.

`cy.loginByAuth0()` visits `/access/online-cloud`, completes Auth0 via `cy.origin()`,
and caches the session when `VITE_AUTH_CACHE=localstorage`.

## Component test patterns

- Avoid stubbing ESM imports in Cypress CT; prefer data-driven setup via providers.
- Build the full provider stack: `GlobalProvider`, Redux store, `OrbitContext`,
and any feature contexts (e.g. `PassageDetailContext`, `UnsavedContext`).
- `useOrbitData` reads records via `memory.cache.query((q) => q.findRecords(type))`
(and subscribes to `cache.liveQuery` only for change notifications). Mock
memory must therefore serve data from **`cache.query`**, not just
`liveQuery.query()`. Route both through one shared `findRecords` so a mock
can't pass via the live-query path while `cache.query` returns `[]`.
- For permission logic (`useStepPermissions`), drive outcomes with real mock records and relationships.
- Localization selectors need `LocalizedStrings` in the correct `strings` slice keys.
- Use stable DOM selectors (ids/data-cy) for assertions.
- When testing navigation that uses `usePassageNavigate`, include a `MemoryRouter`
- `Routes` and stub `UnsavedContext.checkSavedFn` to run immediately.
- `PlanTabSelect` (and parents like `PlanBar`) consume `UnsavedContext`; wrap CT with
`UnsavedContext.Provider` and set `checkSavedFn` to invoke the callback immediately.
- `nextPasId`/`prevPasId` depend on `section.relationships.passages` and
`memory` records; empty relationships mean no neighbors.
- `WorkflowStepsMobile` renders SVG stages (no visible text nodes). Assert on
structure (`svg`, `svg g`) or wrapper labels, not `cy.contains` on step names.
- `WorkflowStepsMobile` depends on `workflow`/`currentstep` from context; keep
defaults intact when overriding to avoid empty steps.
- For `WorkflowStepsMobile`, set viewport and dispatch a `resize` after mount so
the width-driven step list is computed.
- When testing busy/recording state, assert via `setCurrentStep` calls and snack
messages rather than DOM class changes.
- Pagination needs enough steps and a small viewport to expose `prev`/`next`.

## Vite / flaky full CT runs

A cold `npm run cy:run-ct` over all specs may fail a few files with
`Failed to fetch dynamically imported module` when Vite re-optimizes dependencies
mid-run. Re-run the failing spec(s); they typically pass once deps are cached.

## Example: Minimal Strings Slice

```ts
const mockStringsReducer = () => ({
loaded: true,
lang: 'en',
passageDetailStepComplete: new LocalizedStrings({
en: { title: 'Complete' },
}),
});
```
A cold `npm run cy:run-ct` over all specs can fail with `Failed to fetch dynamically imported module` while Vite re-optimizes. Re-run the failing spec once deps are cached.
5 changes: 3 additions & 2 deletions .cursor/rules/jest-testing-takeaways.mdc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
description: Jest testing takeaways for this repo
alwaysApply: true
description: Jest specs in web/. Use when writing or fixing Jest tests, jest.mock, renderHook, useGlobal, useOrbitData, useSelector, or RTL table and dialog queries.
globs: web/**/*.test.ts,web/**/*.test.tsx
alwaysApply: false
---

# Jest Testing Takeaways
Expand Down
50 changes: 50 additions & 0 deletions .cursor/rules/recording-ct-takeaways.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
description: Recording component tests. Use when changing MediaRecord, AudioMediaRecorder, WSAudioPlayer, useWaveSurfer, cy.clock, or waveform duration (TT-7276, TT-7384).
globs: web/**/*MediaRecord*,web/**/AudioMediaRecorder.ts,web/**/WSAudioPlayer.tsx,web/**/useWaveSurfer.tsx,web/**/*recording*
alwaysApply: false
---

# Recording component tests

Prove red and green with `.cursor/rules/tdd-takeaways.mdc`. Run the spec from `web`:

```powershell
npm run cy:run-ct -- --spec=**/MediaRecord.recording.cy.tsx
```

Expect fail without the fix (waveform duration stuck at `0:01`) and pass with the fix.

## Discriminating assertion

Save becoming enabled is a false green: Save turns on with about one second of decodable preview audio while `cy.clock()` shows the full take. After pause, require waveform-backed duration (`#wsAudioDuration`) to match ticks recorded. Parse `m:ss` and assert `>= minSeconds` (3 ticks → `>= 2`, 95 ticks → `>= 90`).

## Production path

- Use real components (`MediaRecord` → `WSAudioPlayer` → `useWaveSurfer`). Leave the player unstubbed for integration regressions.
- Patch browser APIs on `window` (`getUserMedia`, optional `MediaRecorder`, `AudioWorklet`) via `cypress/support/recordingMocks.ts`.
- Force the MediaRecorder fallback (`forceMediaRecorderFallback: true`) when the bug lives in `AudioMediaRecorder`.
- `MockMediaRecorder`: emit one-second WAV fragments per timeslice so concatenated chunks reproduce the decode/merge failure. Use `Date.now()` for elapsed slices (it advances with `cy.tick`; `performance.now()` does not under `cy.clock()`).
- Mock `stop()` flushes pending chunks before setting `state = 'inactive'` (`emitPreviewChunk` checks `state === 'recording'`).

## `cy.clock()`

- `beforeEach(() => cy.clock())` for deterministic preview ticks.
- After click Record, `cy.tick(100)` to flush async `recorder.start()`.
- On pause, `cy.tick(200)` for `AudioMediaRecorder.stop()`'s 100ms chunk wait.
- `assertSaveReady()` may `cy.tick(500)` to flush post-stop UI updates.

## Slice smallest to full

1. Tracer: short record → pause → Save + duration.
2. Long record (~95 ticks) — TT-7384.
3. Duration advances during record — TT-7276.
4. Overdub second take — TT-7276 scenario 2.

## Failures these tests caught

- **Decode trap:** `decodeAudioData` on concatenated WAV fragments can succeed and return only the first RIFF (~1s). Skip accumulated decode when chunks are multiple `audio/wav`; merge per-chunk instead.
- **Async stop:** `await handleChanged()` in `onRecordStop`.
- **Load races:** bump a load generation on `wsStopRecord` so an in-flight preview load cannot overwrite the final stop take in `useWaveSurfer`.
- **Unit backstop:** `AudioMediaRecorder.test.ts` for preview/stop blob shape; component test for the UI path.

When the test passes without the fix, check in order: assertion too weak (Save vs waveform duration); mock chunk shape (native vs mock `MediaRecorder`); harness (`stop()` order, frozen timers, missing `cy.tick` after async stop); production edited by a test-only change (revert production files and re-run red).
Loading
Loading