diff --git a/.claude/commands/qa.md b/.claude/commands/qa.md new file mode 100644 index 000000000..da2e63f3d --- /dev/null +++ b/.claude/commands/qa.md @@ -0,0 +1,504 @@ +Run the cross-platform local QA pipeline for the current working tree changes. + +Follow project rules in `CLAUDE.md` and `AGENTS.md`. + +## Pipeline Overview + +This pipeline has 4 layers: + +1. **Layer 1** — Typecheck + Lint (deterministic, fast gate) +2. **Layer 2** — Diff analysis with platform risk flagging (LLM) +3. **Layer 3** — Unit/component tests (deterministic) +4. **Layer 4a** — Automated UI flows per surface (Playwright, Maestro, TV YAML runner) + **Layer 4b** — Visual screenshot review (LLM) + +Layer 1 failures STOP the pipeline. Layer 3 and 4a failures WARN and continue. Layer 4b findings are informational with severity ratings. + +--- + +## Layer 2: Diff Analysis + +Perform this analysis FIRST because it determines which packages and surfaces are affected for ALL other layers. + +### Step 2.1 — Read the diff + +Run: + +```bash +git diff --name-only HEAD 2>/dev/null; git diff --name-only --cached 2>/dev/null +``` + +If nothing is returned (no changes), also check: + +```bash +git diff --name-only main...HEAD 2>/dev/null +``` + +Collect the full list of changed file paths. + +### Step 2.2 — Identify affected packages + +Map changed files to packages using these rules: + +| Path prefix | Package filter | Surfaces | +| ------------------------ | --------------------- | --------------------------------------------------------- | +| `apps/web/` | `@forge/web` | browser | +| `apps/mobile/` | `@forge/mobile` | iOS, Android | +| `apps/tv/` | `@forge/tv` | tvOS, Android TV | +| `apps/cms/` | `@forge/cms` | (none — CMS changes affect schema, not surfaces directly) | +| `packages/graphql/` | `@forge/graphql` | browser, iOS, Android, tvOS, Android TV | +| `packages/video-player/` | `@forge/video-player` | browser | + +**Dependency graph for shared packages:** + +- `packages/graphql` is consumed by: `@forge/web`, `@forge/mobile`, `@forge/tv` +- `packages/video-player` is consumed by: `@forge/web` only + +When a shared package changes, include ALL its downstream consumer packages in the affected set. + +Build two lists: + +- **affectedFilters**: the `--filter=` arguments for Turbo (e.g., `--filter=@forge/web --filter=@forge/tv`) +- **affectedSurfaces**: the surfaces that need Layer 4a testing (e.g., `browser`, `iOS`, `Android`, `tvOS`, `Android TV`) + +### Step 2.3 — Platform risk analysis + +Scan the full diff content (`git diff` and `git diff --cached`) for these known cross-platform divergence patterns: + +| Pattern | Risk | Surfaces affected | +| -------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------ | +| `Platform.select` or `Platform.OS` | Platform-specific behavior branching | iOS/Android or tvOS/Android TV | +| `StyleSheet.create` with `shadow*` properties | Shadows render differently on Android (elevation) vs iOS | iOS, Android | +| Safe area usage (`useSafeAreaInsets`, `SafeAreaView`) | Inset values differ across devices | iOS, Android | +| `TVFocusGuideView`, `hasTVPreferredFocus` | TV focus navigation patterns | tvOS, Android TV | +| `ScrollView` with focus-related props | Focus fight risk on TV | tvOS, Android TV | +| Absolute positioning (`position: 'absolute'`) in TV components | Focus loss risk on TV | tvOS, Android TV | +| `WebView` usage | WebView crashes on tvOS — must be conditional | tvOS | +| `LayoutAnimation` | Requires explicit enable on Android | Android, Android TV | +| `KeyboardAvoidingView`, keyboard handling | Different behavior iOS vs Android | iOS, Android | +| Gesture handlers (`PanGesture`, `onTouchStart`) | Touch vs D-pad input model | tvOS, Android TV (no touch) | +| `BlurView` / `expo-blur` | iOS only — Android needs fallback | Android | +| `expo-glass-effect` | iOS only — Android needs fallback | Android | +| Video player changes (`expo-video`, `video.js`) | Different player APIs per platform | All | +| GraphQL query/mutation changes | Data shape affects all consumers | All | +| Image handling (`next/image` vs `expo-image`) | Different optimization per platform | browser vs mobile/TV | + +Report each risk found with: + +- The pattern matched +- The file(s) where it appears +- Which surfaces are specifically at risk +- A brief explanation of what could go wrong + +### Step 2.4 — Suggest missing tests + +For each changed file, check whether a colocated test file exists (e.g., `Foo.tsx` -> `Foo.test.tsx`). If not, note it as a coverage gap. Do not generate tests — just flag them. + +### Step 2.5 — Output verdict + +Produce a structured verdict: + +``` +## Layer 2 Verdict + +**Affected packages:** @forge/web, @forge/tv +**Affected surfaces:** browser, tvOS, Android TV +**Turbo filter args:** --filter=@forge/web --filter=@forge/tv + +### Platform risks found +- [P1] `Platform.select` in apps/tv/src/components/QuizModal.tsx — tvOS renders QR code, Android TV renders WebView. Verify both paths. +- [P2] Shadow properties in apps/web/src/components/Card.tsx — may render differently on browsers with different GPU acceleration. + +### Coverage gaps +- apps/web/src/components/SearchOverlay.tsx — no test file found +- apps/tv/src/components/QuizModal.tsx — no test file found + +### Recommended action +Run Layer 4 on: browser, tvOS, Android TV +``` + +If the verdict determines NO surfaces are affected (pure documentation, config-only, or CMS-only changes with no UI impact), report: + +``` +## Layer 2 Verdict + +No UI testing needed. Changes are limited to [description]. +Pipeline complete — PASS. +``` + +And STOP the pipeline here. + +--- + +## Layer 1: Typecheck + Lint + +Using the **affectedFilters** from Layer 2's verdict, run: + +```bash +pnpm turbo run typecheck lint +``` + +For example: + +```bash +pnpm turbo run typecheck lint --filter=@forge/web --filter=@forge/tv +``` + +**If Layer 1 FAILS:** Report the errors clearly and STOP the pipeline. Do not proceed to Layer 3 or 4. + +**If Layer 1 PASSES:** Continue to Layer 3. + +--- + +## Layer 3: Unit/Component Tests + +Using the same **affectedFilters** from Layer 2, run: + +```bash +pnpm turbo run test +``` + +**If Layer 3 FAILS:** Report the failures as WARNINGS but CONTINUE to Layer 4. The UI flows may reveal the root cause visually. + +**If Layer 3 PASSES:** Continue to Layer 4. + +--- + +## Layer 4a: Automated UI Flows + +Before running any flows, check CMS availability: + +```bash +curl -sf http://localhost:1337/graphql -o /dev/null -w "%{http_code}" 2>/dev/null || echo "CMS_DOWN" +``` + +If CMS is down, WARN: + +> CMS is not reachable at http://localhost:1337/graphql. E2E flows will likely screenshot error states. +> Options: (1) Start CMS with `pnpm --filter @forge/cms dev`, (2) Set INTERNAL_GRAPHQL_URL in .env.local to a staging URL. + +Then proceed — some flows may still provide useful screenshots even without CMS. + +### Layer 4 Pre-Flight: Clear previous screenshots + +Delete screenshots from prior `/qa` runs before any flows execute. This keeps Layer 4b visual review focused on the current run and prevents disk bloat — previous runs can leave hundreds of MB of PNGs (TV screenshots alone reach ~150MB). + +Only clean the screenshot subdirectories for surfaces that will actually run this invocation (per Layer 2's verdict), so parallel runs on other surfaces aren't disrupted: + +```bash +# Browser — when `browser` is in affectedSurfaces +rm -rf apps/web/e2e/screenshots/browser + +# iOS mobile — when `iOS` is in affectedSurfaces +rm -rf apps/mobile/e2e/screenshots/ios + +# Android mobile — when `Android` is in affectedSurfaces +rm -rf apps/mobile/e2e/screenshots/android + +# tvOS — when `tvOS` is in affectedSurfaces +rm -rf apps/tv/e2e/screenshots/tvos + +# Android TV — when `Android TV` is in affectedSurfaces +rm -rf apps/tv/e2e/screenshots/androidtv +``` + +Also clean up transient Playwright artifacts if web is affected: + +```bash +rm -rf apps/web/playwright-report apps/web/test-results +``` + +The runners recreate the parent directories on next run, so deleting them is safe. + +### Layer 4 Pre-Flight: Boot simulators and launch apps + +For each affected surface, ensure the simulator is booted AND the target app is running before dispatching any flows. A cold simulator or non-running app is the single most common cause of Layer 4 failures. + +Bundle IDs (from app.json): + +- Mobile: `org.jesusfilm.forgewatch` +- TV: `org.jesusfilm.forgetv` + +**iOS Simulator (mobile) — when `iOS` is affected:** + +```bash +# 1. Boot iPhone simulator if not running +IPHONE_UDID=$(xcrun simctl list devices booted | grep -i "iphone" | grep -oE '[0-9A-F-]{36}' | head -1) +if [ -z "$IPHONE_UDID" ]; then + IPHONE_UDID=$(xcrun simctl list devices available | grep -i "iphone" | grep -v "iPad" | grep -oE '[0-9A-F-]{36}' | head -1) + xcrun simctl boot "$IPHONE_UDID" 2>/dev/null + open -a Simulator + # Wait for boot to complete (up to 60s) + until xcrun simctl list devices | grep "$IPHONE_UDID" | grep -q "Booted"; do sleep 2; done +fi + +# 2. Verify app is installed; WARN and skip iOS if missing +if ! xcrun simctl listapps "$IPHONE_UDID" 2>/dev/null | grep -q "org.jesusfilm.forgewatch"; then + echo "WARN: Mobile app not installed on iPhone simulator. Run: cd apps/mobile && npx expo run:ios" + # Skip iOS Maestro flows +else + # 3. Terminate any stale instance, then launch fresh + xcrun simctl terminate "$IPHONE_UDID" org.jesusfilm.forgewatch 2>/dev/null + xcrun simctl launch "$IPHONE_UDID" org.jesusfilm.forgewatch + # 4. Wait for app to finish loading (Metro bundle + first render) + sleep 10 +fi +``` + +**Android Emulator (mobile) — when `Android` is affected:** + +Use `ANDROID_SERIAL` to target the **phone** emulator specifically; the Android TV emulator must not receive the mobile APK. + +```bash +# 1. Find phone emulator (not TV) +PHONE_EMU=$(adb devices | grep "device$" | awk '{print $1}' | while read id; do + is_tv=$(adb -s "$id" shell getprop ro.build.characteristics 2>/dev/null | grep -c "tv") + [ "$is_tv" = "0" ] && echo "$id" && break +done) + +if [ -z "$PHONE_EMU" ]; then + echo "WARN: No Android phone emulator running. Boot one with:" + echo " \$ANDROID_HOME/emulator/emulator -avd &" + # Skip Android Maestro flows +else + # 2. Verify app is installed + if ! adb -s "$PHONE_EMU" shell pm list packages | grep -q "org.jesusfilm.forgewatch"; then + echo "WARN: Mobile app not installed on phone emulator. Run: cd apps/mobile && npx expo run:android" + # Skip Android Maestro flows + else + # 3. Force-stop and launch + adb -s "$PHONE_EMU" shell am force-stop org.jesusfilm.forgewatch + adb -s "$PHONE_EMU" shell am start -n org.jesusfilm.forgewatch/.MainActivity + sleep 8 + fi +fi +``` + +**tvOS Simulator — when `tvOS` is affected:** + +```bash +# 1. Boot Apple TV simulator if not running +TV_UDID=$(xcrun simctl list devices booted | grep -i "apple tv" | grep -oE '[0-9A-F-]{36}' | head -1) +if [ -z "$TV_UDID" ]; then + TV_UDID=$(xcrun simctl list devices available | grep -i "apple tv" | grep -oE '[0-9A-F-]{36}' | head -1) + xcrun simctl boot "$TV_UDID" 2>/dev/null + open -a Simulator + until xcrun simctl list devices | grep "$TV_UDID" | grep -q "Booted"; do sleep 2; done +fi + +# 2. Verify app is installed; WARN and skip tvOS if missing +if ! xcrun simctl listapps "$TV_UDID" 2>/dev/null | grep -q "org.jesusfilm.forgetv"; then + echo "WARN: TV app not installed on Apple TV simulator. Run: cd apps/tv && EXPO_TV=1 npx expo run:ios" + # Skip tvOS flows +else + # 3. Launch fresh + xcrun simctl terminate "$TV_UDID" org.jesusfilm.forgetv 2>/dev/null + xcrun simctl launch "$TV_UDID" org.jesusfilm.forgetv + sleep 10 +fi +``` + +**Android TV Emulator — when `Android TV` is affected:** + +```bash +# 1. Find TV emulator +TV_EMU=$(adb devices | grep "device$" | awk '{print $1}' | while read id; do + is_tv=$(adb -s "$id" shell getprop ro.build.characteristics 2>/dev/null | grep -c "tv") + [ "$is_tv" -gt "0" ] && echo "$id" && break +done) + +if [ -z "$TV_EMU" ]; then + echo "WARN: No Android TV emulator running. Boot one with:" + echo " \$ANDROID_HOME/emulator/emulator -avd Television_1080p_API_36 &" + # Skip Android TV flows +else + # 2. Verify app is installed + if ! adb -s "$TV_EMU" shell pm list packages | grep -q "org.jesusfilm.forgetv"; then + echo "WARN: TV app not installed on Android TV emulator. Run: cd apps/tv && EXPO_TV=1 npx expo run:android" + # Skip Android TV flows + else + # 3. Force-stop and launch via LEANBACK_LAUNCHER (not LAUNCHER — TV apps use leanback) + adb -s "$TV_EMU" shell am force-stop org.jesusfilm.forgetv + adb -s "$TV_EMU" shell am start -a android.intent.action.MAIN -c android.intent.category.LEANBACK_LAUNCHER -n org.jesusfilm.forgetv/.MainActivity + sleep 8 + fi +fi +``` + +**Failure policy for pre-flight:** + +- If a simulator cannot be booted: WARN and skip that surface's Layer 4a flows. Do not block the whole pipeline. +- If an app is not installed: WARN with the exact build command and skip that surface. App builds take 5-10 minutes and are outside `/qa` scope. +- If app launches but never finishes bundling (Metro still compiling after 30s): WARN and proceed — Maestro/TV runner flows will capture screenshots of the loading state, which is itself useful diagnostic signal. + +After pre-flight completes for each surface, proceed to the corresponding Layer 4a runner. + +### Layer 4a — Browser (Playwright) + +**Run when:** `browser` is in affectedSurfaces. + +```bash +cd apps/web && pnpm run e2e +``` + +This runs all Playwright flows in `apps/web/e2e/flows/` and saves screenshots to `apps/web/e2e/screenshots/browser/`. + +Report results: number of flows passed/failed, any failures with error messages. + +### Layer 4a — iOS + Android (Maestro) + +**Run when:** `iOS` or `Android` is in affectedSurfaces. + +For iOS: + +```bash +cd apps/mobile && maestro test --device ios .maestro/ --output e2e/screenshots/ios/ +``` + +For Android: + +```bash +cd apps/mobile && maestro test --device android .maestro/ --output e2e/screenshots/android/ +``` + +Run iOS and Android in parallel if resources allow (check available simulators first): + +```bash +xcrun simctl list devices booted 2>/dev/null +adb devices 2>/dev/null +``` + +Report results per surface. + +### Layer 4a — tvOS + Android TV (TV YAML Runner) + +**Run when:** `tvOS` or `Android TV` is in affectedSurfaces. + +Both tvOS and Android TV runners are headless (tvOS via `idb` / SimulatorBridge XPC, Android TV via `adb`) — they do NOT use the macOS window system and can run in parallel with each other, with mobile flows, with browser flows, or while the user is typing on the host Mac. + +For Android TV: + +```bash +cd apps/tv && pnpm run e2e:androidtv +``` + +For tvOS: + +```bash +cd apps/tv && pnpm run e2e:tvos +``` + +Report results per surface. + +--- + +## Layer 4b: Visual Review + +After all Layer 4a flows complete, review the captured screenshots. + +### Step 4b.1 — Collect screenshots + +List all screenshots captured during this run: + +```bash +find apps/web/e2e/screenshots apps/mobile/e2e/screenshots apps/tv/e2e/screenshots -name "*.png" 2>/dev/null +``` + +### Step 4b.2 — Review screenshots + +For each surface that ran, review the screenshots and check for: + +- Broken layouts (elements overlapping, clipping, missing) +- Missing content (empty areas that should have data) +- Rendering errors (blank screens, error messages, spinners that never resolved) +- Text truncation or overflow +- Incorrect colors or contrast issues + +### Step 4b.3 — Cross-platform comparison + +For platform pairs, compare screenshots of the same flows: + +**Mobile pair: iOS vs Android** + +- Compare layout consistency +- Check safe area handling differences +- Verify font rendering is acceptable on both +- Check shadow/elevation rendering +- Verify platform-specific UI (blur vs dark overlay, ripple vs opacity) + +**TV pair: tvOS vs Android TV** + +- Compare focus ring appearance +- Check quiz modal rendering (QR code vs WebView) +- Verify carousel navigation states +- Check text rendering and layout consistency +- Verify platform-specific D-pad behavior results + +### Step 4b.4 — Report discrepancies + +Rate each discrepancy: + +- **P0 — Blocking:** Broken layout, missing content, crash, unusable UI +- **P1 — Should Fix:** Noticeable spacing/font/color differences, truncated text, misaligned elements +- **P2 — Cosmetic:** Minor rendering variance, slight anti-aliasing differences, subpixel shifts + +--- + +## Final Report + +After all layers complete, produce a summary report: + +``` +## QA Pipeline Report + +### Layer 1: Typecheck + Lint +PASS (or FAIL with details) + +### Layer 2: Diff Analysis +Affected: [surfaces] +Risks: [count] platform risks identified +Gaps: [count] missing test files + +### Layer 3: Unit/Component Tests +PASS / WARN: [N] test(s) failed (with details) + +### Layer 4a: Automated UI Flows +- Browser: [N] flows passed, [N] failed +- iOS: [N] flows passed, [N] failed +- Android: [N] flows passed, [N] failed +- tvOS: [N] flows passed, [N] failed +- Android TV: [N] flows passed, [N] failed + +### Layer 4b: Visual Review +- [N] discrepancies found + - P0: [count] + - P1: [count] + - P2: [count] +- [Details of each discrepancy] + +### Overall Verdict +[PASS / WARN / FAIL] — [summary sentence] +``` + +--- + +## Pre-Flight Checks + +Simulator/app readiness is handled inline in the "Layer 4 Pre-Flight: Boot simulators and launch apps" section above — that section boots simulators if cold, verifies apps are installed, and launches them fresh before dispatching flows. + +For the browser surface, the dev server readiness check is: + +```bash +curl -sf http://localhost:3000 -o /dev/null && echo "WEB_OK" || echo "WEB_DOWN" +``` + +If the web surface is affected but `WEB_DOWN`, start it with `pnpm --filter @forge/web dev` or rely on Playwright's `webServer` config (omit `PW_SKIP_WEBSERVER`) to spawn one. + +## Screenshot Reading for Layer 4b + +When reviewing screenshots in Layer 4b, use the Read tool to view each PNG file. Claude can read image files directly. For efficiency: + +1. List all screenshots first +2. Read the most important screenshots (hero screens, error states, interactive states) +3. For cross-platform comparison, read matching screenshots from both platforms side-by-side +4. Cap review to ~5 screenshots per flow to stay within the 8-minute target diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 6ae4c8263..8688a1700 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -31,3 +31,6 @@ yarn-error.* # typescript *.tsbuildinfo + +# e2e screenshots +e2e/screenshots/ diff --git a/apps/mobile/.maestro/accordion-cta.yaml b/apps/mobile/.maestro/accordion-cta.yaml new file mode 100644 index 000000000..6b2ba22c2 --- /dev/null +++ b/apps/mobile/.maestro/accordion-cta.yaml @@ -0,0 +1,16 @@ +appId: org.jesusfilm.forgewatch +tags: + - accordion + - cta +--- +# Accordion CTA — Header button tap +- launchApp +- waitForAnimationToEnd +- scroll + +# Tap CTA button in header +- tapOn: + id: "accordion-cta" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/accordion-cta/tapped diff --git a/apps/mobile/.maestro/accordion-questions.yaml b/apps/mobile/.maestro/accordion-questions.yaml new file mode 100644 index 000000000..511681f84 --- /dev/null +++ b/apps/mobile/.maestro/accordion-questions.yaml @@ -0,0 +1,31 @@ +appId: org.jesusfilm.forgewatch +tags: + - accordion + - questions +--- +# Related Questions Accordion — Expand, collapse, single open +- launchApp +- waitForAnimationToEnd +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/accordion-questions/visible + +# Tap first question +- tapOn: + id: "accordion-question-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/accordion-questions/expanded + +# Tap second question (first should collapse) +- tapOn: + id: "accordion-question-1" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/accordion-questions/second-expanded + +# Collapse +- tapOn: + id: "accordion-question-1" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/accordion-questions/all-collapsed diff --git a/apps/mobile/.maestro/appstate-background.yaml b/apps/mobile/.maestro/appstate-background.yaml new file mode 100644 index 000000000..f7fe1cb78 --- /dev/null +++ b/apps/mobile/.maestro/appstate-background.yaml @@ -0,0 +1,15 @@ +appId: org.jesusfilm.forgewatch +tags: + - appstate + - lifecycle +--- +# AppState Background/Foreground — Video pause/resume +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/appstate-background/foreground + +# Note: Maestro cannot directly simulate app backgrounding, but we can +# verify the app loads correctly after relaunch +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/appstate-background/relaunched diff --git a/apps/mobile/.maestro/appstate-video.yaml b/apps/mobile/.maestro/appstate-video.yaml new file mode 100644 index 000000000..dd86842cc --- /dev/null +++ b/apps/mobile/.maestro/appstate-video.yaml @@ -0,0 +1,23 @@ +appId: org.jesusfilm.forgewatch +tags: + - appstate + - video +--- +# AppState Video — Mute persists, hero re-mutes on nav away +- launchApp +- waitForAnimationToEnd + +# Toggle mute +- tapOn: + id: "mute-button" + optional: true +- takeScreenshot: ../e2e/screenshots/${platform}/appstate-video/mute-toggled + +# Navigate away and back +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd +- tapOn: + id: "tab-home" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/appstate-video/returned-home diff --git a/apps/mobile/.maestro/carousel-bible-quotes.yaml b/apps/mobile/.maestro/carousel-bible-quotes.yaml new file mode 100644 index 000000000..37b6b4048 --- /dev/null +++ b/apps/mobile/.maestro/carousel-bible-quotes.yaml @@ -0,0 +1,29 @@ +appId: org.jesusfilm.forgewatch +tags: + - carousel + - bible-quotes +--- +# Bible Quotes Carousel — Paged, pagination dots, share +- launchApp +- waitForAnimationToEnd +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-bible-quotes/visible + +# Swipe to next quote +- swipe: + direction: LEFT +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-bible-quotes/next-quote + +# Share button +- tapOn: + id: "share-quote" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-bible-quotes/share-sheet + +# CTA link +- tapOn: + id: "quote-cta" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-bible-quotes/cta-tapped diff --git a/apps/mobile/.maestro/carousel-media.yaml b/apps/mobile/.maestro/carousel-media.yaml new file mode 100644 index 000000000..9afbb3a46 --- /dev/null +++ b/apps/mobile/.maestro/carousel-media.yaml @@ -0,0 +1,22 @@ +appId: org.jesusfilm.forgewatch +tags: + - carousel + - media +--- +# Media Collection Carousel — 3:4 cards, badges, labels +- launchApp +- waitForAnimationToEnd +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-media/visible + +# Swipe through +- swipe: + direction: LEFT +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-media/swiped + +# Tap a media item +- tapOn: + id: "media-collection-item-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-media/tapped diff --git a/apps/mobile/.maestro/carousel-navigation.yaml b/apps/mobile/.maestro/carousel-navigation.yaml new file mode 100644 index 000000000..4733fa85c --- /dev/null +++ b/apps/mobile/.maestro/carousel-navigation.yaml @@ -0,0 +1,21 @@ +appId: org.jesusfilm.forgewatch +tags: + - carousel + - navigation +--- +# Navigation Carousel — Scroll-to-section, category labels +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-navigation/visible + +# Tap a navigation item +- tapOn: + id: "nav-carousel-item-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-navigation/scrolled-to-section + +# Swipe navigation carousel +- swipe: + direction: LEFT +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-navigation/swiped diff --git a/apps/mobile/.maestro/carousel-video.yaml b/apps/mobile/.maestro/carousel-video.yaml new file mode 100644 index 000000000..52c0f79c8 --- /dev/null +++ b/apps/mobile/.maestro/carousel-video.yaml @@ -0,0 +1,22 @@ +appId: org.jesusfilm.forgewatch +tags: + - carousel + - video +--- +# Video Carousel — Horizontal scroll, portrait cards, tap navigation +- launchApp +- waitForAnimationToEnd +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-video/visible + +# Swipe carousel horizontally +- swipe: + direction: LEFT +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-video/swiped + +# Tap a card +- tapOn: + id: "video-carousel-card-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/carousel-video/card-tapped diff --git a/apps/mobile/.maestro/collection-player-states.yaml b/apps/mobile/.maestro/collection-player-states.yaml new file mode 100644 index 000000000..b3e1a1caf --- /dev/null +++ b/apps/mobile/.maestro/collection-player-states.yaml @@ -0,0 +1,20 @@ +appId: org.jesusfilm.forgewatch +tags: + - collection + - states +--- +# Collection Player States — Loading, no playable, disabled items +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "collection-card-0" + optional: true +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player-states/loading + +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player-states/loaded + +# Scroll to see playlist items with disabled state +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player-states/playlist-items diff --git a/apps/mobile/.maestro/collection-player-switch.yaml b/apps/mobile/.maestro/collection-player-switch.yaml new file mode 100644 index 000000000..76d51771a --- /dev/null +++ b/apps/mobile/.maestro/collection-player-switch.yaml @@ -0,0 +1,24 @@ +appId: org.jesusfilm.forgewatch +tags: + - collection + - switching +--- +# Collection Player Switch — Tap item to switch video +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "collection-card-0" + optional: true +- waitForAnimationToEnd + +# Tap a different playlist item +- scroll +- tapOn: + id: "playlist-item-1" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player-switch/switched + +# Verify active item badge +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player-switch/active-badge diff --git a/apps/mobile/.maestro/collection-player.yaml b/apps/mobile/.maestro/collection-player.yaml new file mode 100644 index 000000000..e3a6b01ac --- /dev/null +++ b/apps/mobile/.maestro/collection-player.yaml @@ -0,0 +1,23 @@ +appId: org.jesusfilm.forgewatch +tags: + - collection + - player +--- +# Collection Player — Playlist, active item, auto-advance +- launchApp +- waitForAnimationToEnd +- scroll + +# Navigate to a collection +- tapOn: + id: "collection-card-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player/loaded + +# Verify 16:9 player +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player/player-dimensions + +# Scroll to playlist +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/collection-player/playlist diff --git a/apps/mobile/.maestro/discover-clear.yaml b/apps/mobile/.maestro/discover-clear.yaml new file mode 100644 index 000000000..65ba6a612 --- /dev/null +++ b/apps/mobile/.maestro/discover-clear.yaml @@ -0,0 +1,20 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover +--- +# Discover Clear Search — Reset to empty state +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-clear/with-results + +- eraseText +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-clear/cleared diff --git a/apps/mobile/.maestro/discover-error.yaml b/apps/mobile/.maestro/discover-error.yaml new file mode 100644 index 000000000..7b70fcfa5 --- /dev/null +++ b/apps/mobile/.maestro/discover-error.yaml @@ -0,0 +1,18 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - error +--- +# Discover Error — Network error, rate limit handling +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +# Search to trigger potential error states +- tapOn: + id: "search-input" + optional: true +- inputText: "test" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-error/state diff --git a/apps/mobile/.maestro/discover-keyboard.yaml b/apps/mobile/.maestro/discover-keyboard.yaml new file mode 100644 index 000000000..587d4f010 --- /dev/null +++ b/apps/mobile/.maestro/discover-keyboard.yaml @@ -0,0 +1,20 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - keyboard +--- +# Discover Keyboard — Dismiss on scroll +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- takeScreenshot: ../e2e/screenshots/${platform}/discover-keyboard/keyboard-visible + +# Scroll to dismiss keyboard +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/discover-keyboard/keyboard-dismissed diff --git a/apps/mobile/.maestro/discover-no-results.yaml b/apps/mobile/.maestro/discover-no-results.yaml new file mode 100644 index 000000000..1a9a482c6 --- /dev/null +++ b/apps/mobile/.maestro/discover-no-results.yaml @@ -0,0 +1,17 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - empty +--- +# Discover No Results — Empty state for nonexistent query +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "xyznonexistentquery12345" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-no-results/empty-state diff --git a/apps/mobile/.maestro/discover-pagination.yaml b/apps/mobile/.maestro/discover-pagination.yaml new file mode 100644 index 000000000..33303c3a6 --- /dev/null +++ b/apps/mobile/.maestro/discover-pagination.yaml @@ -0,0 +1,27 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - pagination +--- +# Discover Pagination — Load more results +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- waitForAnimationToEnd + +# Scroll to load more +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/discover-pagination/scrolled + +# Tap load more if visible +- tapOn: + text: "Load more" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-pagination/more-loaded diff --git a/apps/mobile/.maestro/discover-rapid-typing.yaml b/apps/mobile/.maestro/discover-rapid-typing.yaml new file mode 100644 index 000000000..0154c9f3c --- /dev/null +++ b/apps/mobile/.maestro/discover-rapid-typing.yaml @@ -0,0 +1,23 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - debounce +--- +# Discover Rapid Typing — Only latest query fires (requestId) +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "first" +- waitForAnimationToEnd +- eraseText +- inputText: "second" +- waitForAnimationToEnd +- eraseText +- inputText: "Jesus" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-rapid-typing/latest-result diff --git a/apps/mobile/.maestro/discover-result-tap.yaml b/apps/mobile/.maestro/discover-result-tap.yaml new file mode 100644 index 000000000..6aa811fcf --- /dev/null +++ b/apps/mobile/.maestro/discover-result-tap.yaml @@ -0,0 +1,24 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - navigation +--- +# Discover Result Tap — Navigate to experience from search +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-result-tap/results + +# Tap first result card +- tapOn: + id: "search-result-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-result-tap/navigated diff --git a/apps/mobile/.maestro/discover-search.yaml b/apps/mobile/.maestro/discover-search.yaml new file mode 100644 index 000000000..7d9e0f3c6 --- /dev/null +++ b/apps/mobile/.maestro/discover-search.yaml @@ -0,0 +1,23 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - search +--- +# Discover/Search Screen — Search input and results +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-search/initial + +# Type search query +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-search/query-typed + +# Wait for results +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-search/results-loaded diff --git a/apps/mobile/.maestro/discover-skeleton.yaml b/apps/mobile/.maestro/discover-skeleton.yaml new file mode 100644 index 000000000..e1a3fdef2 --- /dev/null +++ b/apps/mobile/.maestro/discover-skeleton.yaml @@ -0,0 +1,22 @@ +appId: org.jesusfilm.forgewatch +tags: + - discover + - loading +--- +# Discover Skeleton Loading — Shimmer cards after 500ms delay +- launchApp +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd + +# Type to trigger search +- tapOn: + id: "search-input" + optional: true +- inputText: "Jesus" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-skeleton/shimmer-loading + +# Wait for results to replace skeleton +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/discover-skeleton/results-loaded diff --git a/apps/mobile/.maestro/error-home.yaml b/apps/mobile/.maestro/error-home.yaml new file mode 100644 index 000000000..e439af1ea --- /dev/null +++ b/apps/mobile/.maestro/error-home.yaml @@ -0,0 +1,9 @@ +appId: org.jesusfilm.forgewatch +tags: + - error + - home +--- +# Error Home — "Something went wrong" + Retry +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/error-home/state diff --git a/apps/mobile/.maestro/error-library.yaml b/apps/mobile/.maestro/error-library.yaml new file mode 100644 index 000000000..5a6add83a --- /dev/null +++ b/apps/mobile/.maestro/error-library.yaml @@ -0,0 +1,11 @@ +appId: org.jesusfilm.forgewatch +tags: + - error + - library +--- +# Error Library — "Failed to load experiences" + Try Again +- launchApp +- tapOn: + id: "tab-library" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/error-library/state diff --git a/apps/mobile/.maestro/error-network.yaml b/apps/mobile/.maestro/error-network.yaml new file mode 100644 index 000000000..9809d088c --- /dev/null +++ b/apps/mobile/.maestro/error-network.yaml @@ -0,0 +1,19 @@ +appId: org.jesusfilm.forgewatch +tags: + - error + - network +--- +# Error Network — Apollo error display and retry +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/error-network/state + +# Retry button if error visible +- tapOn: + text: "Retry" + optional: true +- tapOn: + text: "Try Again" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/error-network/after-retry diff --git a/apps/mobile/.maestro/error-startup.yaml b/apps/mobile/.maestro/error-startup.yaml new file mode 100644 index 000000000..ada10b7d2 --- /dev/null +++ b/apps/mobile/.maestro/error-startup.yaml @@ -0,0 +1,8 @@ +appId: org.jesusfilm.forgewatch +tags: + - error +--- +# Error Startup — App launch error handling +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/error-startup/state diff --git a/apps/mobile/.maestro/hero-fallback.yaml b/apps/mobile/.maestro/hero-fallback.yaml new file mode 100644 index 000000000..94eaea3b1 --- /dev/null +++ b/apps/mobile/.maestro/hero-fallback.yaml @@ -0,0 +1,9 @@ +appId: org.jesusfilm.forgewatch +tags: + - hero + - fallback +--- +# Hero Fallback — Image when no stream available +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/hero-fallback/hero-state diff --git a/apps/mobile/.maestro/hero-renderer.yaml b/apps/mobile/.maestro/hero-renderer.yaml new file mode 100644 index 000000000..373c645aa --- /dev/null +++ b/apps/mobile/.maestro/hero-renderer.yaml @@ -0,0 +1,19 @@ +appId: org.jesusfilm.forgewatch +tags: + - hero + - renderer +--- +# Video Hero Renderer — Stream, thumbnail, CTA, gradients +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/hero-renderer/hero-visible + +# Content overlay elements +- takeScreenshot: ../e2e/screenshots/${platform}/hero-renderer/overlay-content + +# CTA button +- tapOn: + id: "hero-cta" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/hero-renderer/cta-tapped diff --git a/apps/mobile/.maestro/home-blur-overlay.yaml b/apps/mobile/.maestro/home-blur-overlay.yaml new file mode 100644 index 000000000..fdc38aff7 --- /dev/null +++ b/apps/mobile/.maestro/home-blur-overlay.yaml @@ -0,0 +1,16 @@ +appId: org.jesusfilm.forgewatch +tags: + - home + - visual +--- +# Home Blur Overlay — iOS BlurView vs Android dark overlay +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-blur-overlay/no-scroll + +# Scroll to trigger blur overlay +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-blur-overlay/partial-scroll + +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-blur-overlay/full-scroll diff --git a/apps/mobile/.maestro/home-header-buttons.yaml b/apps/mobile/.maestro/home-header-buttons.yaml new file mode 100644 index 000000000..a02a00faa --- /dev/null +++ b/apps/mobile/.maestro/home-header-buttons.yaml @@ -0,0 +1,27 @@ +appId: org.jesusfilm.forgewatch +tags: + - home + - header +--- +# Home Header Buttons — Search and Profile navigation +- launchApp +- waitForAnimationToEnd + +# Search button navigates to Discover +- tapOn: + id: "header-search" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-header-buttons/search-navigate + +# Go back to home +- tapOn: + id: "tab-home" +- waitForAnimationToEnd + +# Profile button navigates to Profile +- tapOn: + id: "header-profile" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-header-buttons/profile-navigate diff --git a/apps/mobile/.maestro/home-hero.yaml b/apps/mobile/.maestro/home-hero.yaml new file mode 100644 index 000000000..181535e0b --- /dev/null +++ b/apps/mobile/.maestro/home-hero.yaml @@ -0,0 +1,24 @@ +appId: org.jesusfilm.forgewatch +tags: + - home + - hero +--- +# Home Screen Hero — Video, mute, scroll behavior +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-hero/hero-visible + +# Mute toggle +- tapOn: + id: "mute-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-hero/mute-toggled + +# Scroll down to trigger hero pause +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-hero/scrolled-down + +# Scroll back up to resume +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-hero/scrolled-up diff --git a/apps/mobile/.maestro/home-loading.yaml b/apps/mobile/.maestro/home-loading.yaml new file mode 100644 index 000000000..990a60609 --- /dev/null +++ b/apps/mobile/.maestro/home-loading.yaml @@ -0,0 +1,12 @@ +appId: org.jesusfilm.forgewatch +tags: + - home + - loading +--- +# Home Screen Loading States +- launchApp +- takeScreenshot: ../e2e/screenshots/${platform}/home-loading/initial-load + +# Wait for content to appear +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/home-loading/content-loaded diff --git a/apps/mobile/.maestro/home-sections.yaml b/apps/mobile/.maestro/home-sections.yaml new file mode 100644 index 000000000..ab4b9b3b1 --- /dev/null +++ b/apps/mobile/.maestro/home-sections.yaml @@ -0,0 +1,19 @@ +appId: org.jesusfilm.forgewatch +tags: + - home + - sections +--- +# Home Screen Sections — Verify sections render in order +- launchApp +- waitForAnimationToEnd + +# Navigation carousel at top +- takeScreenshot: ../e2e/screenshots/${platform}/home-sections/top-sections + +# Scroll to mid sections +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-sections/mid-sections + +# Scroll further +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/home-sections/bottom-sections diff --git a/apps/mobile/.maestro/library-list.yaml b/apps/mobile/.maestro/library-list.yaml new file mode 100644 index 000000000..8b9397fd7 --- /dev/null +++ b/apps/mobile/.maestro/library-list.yaml @@ -0,0 +1,14 @@ +appId: org.jesusfilm.forgewatch +tags: + - library +--- +# Library Screen — Experience list with FlashList +- launchApp +- tapOn: + id: "tab-library" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/library-list/loaded + +# Scroll through experiences +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/library-list/scrolled diff --git a/apps/mobile/.maestro/library-selection.yaml b/apps/mobile/.maestro/library-selection.yaml new file mode 100644 index 000000000..ece7b5cae --- /dev/null +++ b/apps/mobile/.maestro/library-selection.yaml @@ -0,0 +1,20 @@ +appId: org.jesusfilm.forgewatch +tags: + - library + - selection +--- +# Library Selection — Select experience, navigate home +- launchApp +- tapOn: + id: "tab-library" +- waitForAnimationToEnd + +# Tap first experience card +- tapOn: + id: "experience-card-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/library-selection/selected + +# Should navigate to Home with selected experience +- takeScreenshot: ../e2e/screenshots/${platform}/library-selection/home-updated diff --git a/apps/mobile/.maestro/library-states.yaml b/apps/mobile/.maestro/library-states.yaml new file mode 100644 index 000000000..56144546b --- /dev/null +++ b/apps/mobile/.maestro/library-states.yaml @@ -0,0 +1,16 @@ +appId: org.jesusfilm.forgewatch +tags: + - library + - states +--- +# Library States — Loading, error, empty, active card styling +- launchApp +- tapOn: + id: "tab-library" +- takeScreenshot: ../e2e/screenshots/${platform}/library-states/loading + +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/library-states/loaded + +# Verify card styling — active vs inactive +- takeScreenshot: ../e2e/screenshots/${platform}/library-states/card-styles diff --git a/apps/mobile/.maestro/library-thumbnail.yaml b/apps/mobile/.maestro/library-thumbnail.yaml new file mode 100644 index 000000000..10c640394 --- /dev/null +++ b/apps/mobile/.maestro/library-thumbnail.yaml @@ -0,0 +1,10 @@ +appId: org.jesusfilm.forgewatch +tags: + - library +--- +# Library Thumbnail — ogImage or gradient fallback +- launchApp +- tapOn: + id: "tab-library" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/library-thumbnail/cards diff --git a/apps/mobile/.maestro/platform-blur-overlay.yaml b/apps/mobile/.maestro/platform-blur-overlay.yaml new file mode 100644 index 000000000..47b932713 --- /dev/null +++ b/apps/mobile/.maestro/platform-blur-overlay.yaml @@ -0,0 +1,12 @@ +appId: org.jesusfilm.forgewatch +tags: + - platform + - visual +--- +# Platform Blur — iOS BlurView vs Android dark overlay +- launchApp +- waitForAnimationToEnd + +# Scroll to trigger blur overlay +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/platform-blur-overlay/blur-state diff --git a/apps/mobile/.maestro/platform-glass-effect.yaml b/apps/mobile/.maestro/platform-glass-effect.yaml new file mode 100644 index 000000000..f3ca1f5ea --- /dev/null +++ b/apps/mobile/.maestro/platform-glass-effect.yaml @@ -0,0 +1,9 @@ +appId: org.jesusfilm.forgewatch +tags: + - platform + - glass +--- +# Platform Glass Effect — iOS glass vs Android solid fallback +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/platform-glass-effect/header diff --git a/apps/mobile/.maestro/platform-interactions.yaml b/apps/mobile/.maestro/platform-interactions.yaml new file mode 100644 index 000000000..f5ab032c8 --- /dev/null +++ b/apps/mobile/.maestro/platform-interactions.yaml @@ -0,0 +1,17 @@ +appId: org.jesusfilm.forgewatch +tags: + - platform + - interactions +--- +# Platform Interactions — Ripple (Android) vs opacity (iOS) +- launchApp +- waitForAnimationToEnd + +# Tap a button to see press feedback +- tapOn: + id: "tab-discover" +- takeScreenshot: ../e2e/screenshots/${platform}/platform-interactions/tab-press + +- tapOn: + id: "tab-library" +- takeScreenshot: ../e2e/screenshots/${platform}/platform-interactions/tab-press-2 diff --git a/apps/mobile/.maestro/platform-safe-areas.yaml b/apps/mobile/.maestro/platform-safe-areas.yaml new file mode 100644 index 000000000..556c20d44 --- /dev/null +++ b/apps/mobile/.maestro/platform-safe-areas.yaml @@ -0,0 +1,19 @@ +appId: org.jesusfilm.forgewatch +tags: + - platform + - safe-areas +--- +# Platform Safe Areas — Notch, Dynamic Island, insets +- launchApp +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/platform-safe-areas/home + +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/platform-safe-areas/discover + +- tapOn: + id: "tab-library" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/platform-safe-areas/library diff --git a/apps/mobile/.maestro/quiz-button.yaml b/apps/mobile/.maestro/quiz-button.yaml new file mode 100644 index 000000000..c2581b41e --- /dev/null +++ b/apps/mobile/.maestro/quiz-button.yaml @@ -0,0 +1,25 @@ +appId: org.jesusfilm.forgewatch +tags: + - quiz +--- +# Quiz Button + Modal — Gradient, modal, WebView +- launchApp +- waitForAnimationToEnd +- scroll + +# Quiz button visible +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-button/visible + +# Tap quiz button +- tapOn: + id: "quiz-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-button/modal-open + +# Close modal +- tapOn: + id: "modal-close" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-button/modal-closed diff --git a/apps/mobile/.maestro/quiz-validation.yaml b/apps/mobile/.maestro/quiz-validation.yaml new file mode 100644 index 000000000..e9b8e4af9 --- /dev/null +++ b/apps/mobile/.maestro/quiz-validation.yaml @@ -0,0 +1,15 @@ +appId: org.jesusfilm.forgewatch +tags: + - quiz + - security +--- +# Quiz URL Validation — HTTPS, nextstep.is domain +- launchApp +- waitForAnimationToEnd +- scroll + +- tapOn: + id: "quiz-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-validation/webview-state diff --git a/apps/mobile/.maestro/quiz-webview.yaml b/apps/mobile/.maestro/quiz-webview.yaml new file mode 100644 index 000000000..db3f0224d --- /dev/null +++ b/apps/mobile/.maestro/quiz-webview.yaml @@ -0,0 +1,18 @@ +appId: org.jesusfilm.forgewatch +tags: + - quiz + - webview +--- +# Quiz WebView — Loading, loaded, error states +- launchApp +- waitForAnimationToEnd +- scroll + +- tapOn: + id: "quiz-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-webview/loading + +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/quiz-webview/loaded diff --git a/apps/mobile/.maestro/tab-navigation-states.yaml b/apps/mobile/.maestro/tab-navigation-states.yaml new file mode 100644 index 000000000..5149cca20 --- /dev/null +++ b/apps/mobile/.maestro/tab-navigation-states.yaml @@ -0,0 +1,31 @@ +appId: org.jesusfilm.forgewatch +tags: + - navigation +--- +# Tab Navigation States — Icon colors, labels, persistence +- launchApp + +# Verify tab icon active state on Home +- assertVisible: + id: "tab-home" +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation-states/home-active + +# Tab background color +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation-states/tab-bar-bg + +# Tab labels display +- assertVisible: + text: "Home" +- assertVisible: + text: "Discover" +- assertVisible: + text: "Library" + +# Navigation persistence — visit detail then return +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd +- tapOn: + id: "tab-home" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation-states/state-preserved diff --git a/apps/mobile/.maestro/tab-navigation.yaml b/apps/mobile/.maestro/tab-navigation.yaml new file mode 100644 index 000000000..1269b08d8 --- /dev/null +++ b/apps/mobile/.maestro/tab-navigation.yaml @@ -0,0 +1,32 @@ +appId: org.jesusfilm.forgewatch +tags: + - navigation + - smoke +--- +# Tab Navigation — Switch between all 4 tabs +- launchApp +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation/home-tab + +# Switch to Discover tab +- tapOn: + id: "tab-discover" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation/discover-tab + +# Switch to Library tab +- tapOn: + id: "tab-library" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation/library-tab + +# Switch to Profile tab +- tapOn: + id: "tab-profile" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation/profile-tab + +# Return to Home tab +- tapOn: + id: "tab-home" +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/tab-navigation/home-return diff --git a/apps/mobile/.maestro/video-detail-back.yaml b/apps/mobile/.maestro/video-detail-back.yaml new file mode 100644 index 000000000..f91fe92f2 --- /dev/null +++ b/apps/mobile/.maestro/video-detail-back.yaml @@ -0,0 +1,21 @@ +appId: org.jesusfilm.forgewatch +tags: + - video + - navigation +--- +# Video Detail Back — Return to correct tab +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "video-card-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-back/detail-screen + +# Back button +- tapOn: + id: "back-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-back/returned-home diff --git a/apps/mobile/.maestro/video-detail-description.yaml b/apps/mobile/.maestro/video-detail-description.yaml new file mode 100644 index 000000000..c51940302 --- /dev/null +++ b/apps/mobile/.maestro/video-detail-description.yaml @@ -0,0 +1,29 @@ +appId: org.jesusfilm.forgewatch +tags: + - video + - description +--- +# Video Detail Description — Read more / Show less toggle +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "video-card-0" + optional: true +- waitForAnimationToEnd + +# Scroll to description +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-description/collapsed + +# Tap Read more +- tapOn: + text: "Read more" + optional: true +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-description/expanded + +# Tap Show less +- tapOn: + text: "Show less" + optional: true +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-description/collapsed-again diff --git a/apps/mobile/.maestro/video-detail-share.yaml b/apps/mobile/.maestro/video-detail-share.yaml new file mode 100644 index 000000000..6222e9c31 --- /dev/null +++ b/apps/mobile/.maestro/video-detail-share.yaml @@ -0,0 +1,20 @@ +appId: org.jesusfilm.forgewatch +tags: + - video + - share +--- +# Video Detail Share — Native share sheet +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "video-card-0" + optional: true +- waitForAnimationToEnd + +# Tap share button +- tapOn: + id: "share-button" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-share/share-sheet diff --git a/apps/mobile/.maestro/video-detail-siblings.yaml b/apps/mobile/.maestro/video-detail-siblings.yaml new file mode 100644 index 000000000..1af37813f --- /dev/null +++ b/apps/mobile/.maestro/video-detail-siblings.yaml @@ -0,0 +1,17 @@ +appId: org.jesusfilm.forgewatch +tags: + - video + - siblings +--- +# Video Detail Siblings — Related videos below player +- launchApp +- waitForAnimationToEnd +- scroll +- tapOn: + id: "video-card-0" + optional: true +- waitForAnimationToEnd + +# Scroll to sibling content +- scroll +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail-siblings/related-content diff --git a/apps/mobile/.maestro/video-detail.yaml b/apps/mobile/.maestro/video-detail.yaml new file mode 100644 index 000000000..299c8a632 --- /dev/null +++ b/apps/mobile/.maestro/video-detail.yaml @@ -0,0 +1,26 @@ +appId: org.jesusfilm.forgewatch +tags: + - video + - detail +--- +# Video Detail Screen — Route, header, player, description +- launchApp +- waitForAnimationToEnd + +# Navigate to a video via home content +- scroll +- tapOn: + id: "video-card-0" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail/loaded + +# Header elements +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail/header + +# Tap thumbnail to play +- tapOn: + id: "video-thumbnail" + optional: true +- waitForAnimationToEnd +- takeScreenshot: ../e2e/screenshots/${platform}/video-detail/playing diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index d96fb0e88..3efcc6a51 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -27,6 +27,7 @@ export default function TabLayout() { name="index" options={{ title: "Home", + tabBarButtonTestID: "tab-home", tabBarIcon: ({ color, size }) => ( ), @@ -36,6 +37,7 @@ export default function TabLayout() { name="watch" options={{ title: "Discover", + tabBarButtonTestID: "tab-discover", headerShown: true, headerTitle: "Discover", headerStyle: { backgroundColor: BG_COLOR }, @@ -50,6 +52,7 @@ export default function TabLayout() { name="library" options={{ title: "Library", + tabBarButtonTestID: "tab-library", tabBarIcon: ({ color, size }) => ( ), @@ -59,6 +62,7 @@ export default function TabLayout() { name="profile" options={{ title: "Profile", + tabBarButtonTestID: "tab-profile", tabBarIcon: ({ color, size }) => ( ), diff --git a/apps/mobile/app/(tabs)/library.tsx b/apps/mobile/app/(tabs)/library.tsx index aaed47bc6..8a8c21a36 100644 --- a/apps/mobile/app/(tabs)/library.tsx +++ b/apps/mobile/app/(tabs)/library.tsx @@ -96,9 +96,10 @@ export default function LibraryScreen() { data={experiences} keyExtractor={(item) => item.documentId} contentContainerStyle={styles.listContent} - renderItem={({ item }) => ( + renderItem={({ item, index }) => ( @@ -112,6 +113,7 @@ export default function LibraryScreen() { function ExperienceCard({ experience, + index, isActive, onSelect, }: { @@ -122,6 +124,7 @@ function ExperienceCard({ metaDescription: string | null ogImage: { url: string; alternativeText: string | null } | null } + index: number isActive: boolean onSelect: (slug: string) => void }) { @@ -130,6 +133,7 @@ function ExperienceCard({ return ( onSelect(experience.slug)} style={[styles.card, isActive && styles.cardActive]} accessibilityRole="button" diff --git a/apps/mobile/app/(tabs)/watch.tsx b/apps/mobile/app/(tabs)/watch.tsx index e4cde556d..f719ba228 100644 --- a/apps/mobile/app/(tabs)/watch.tsx +++ b/apps/mobile/app/(tabs)/watch.tsx @@ -257,6 +257,7 @@ export default function DiscoverScreen() { ( router.back()} accessibilityRole="button" accessibilityLabel="Go back" @@ -204,6 +205,7 @@ export default function RootLayout() { headerTitleAlign: "center", headerLeft: () => ( router.back()} accessibilityRole="button" accessibilityLabel="Go back" diff --git a/apps/mobile/app/collection/[sectionKey].tsx b/apps/mobile/app/collection/[sectionKey].tsx index e4a6229c3..d7aaf3806 100644 --- a/apps/mobile/app/collection/[sectionKey].tsx +++ b/apps/mobile/app/collection/[sectionKey].tsx @@ -247,6 +247,7 @@ function CollectionPlayerContent({ return ( [ styles.row, isActive && styles.rowActive, diff --git a/apps/mobile/app/video/[sectionKey].tsx b/apps/mobile/app/video/[sectionKey].tsx index 54009bd99..b54189139 100644 --- a/apps/mobile/app/video/[sectionKey].tsx +++ b/apps/mobile/app/video/[sectionKey].tsx @@ -97,6 +97,7 @@ function VideoDetailContent({ headerTitle: title ?? "", headerRight: () => ( { const parts = [`Check out "${displayTitle}" on JesusFilm!`] if (shareUrl != null) parts.push(shareUrl) @@ -201,6 +202,7 @@ function VideoDetailContent({ /> {!hasStarted && thumbnailUrl != null && ( .env.local.tmp && mv .env.local.tmp .env.local", "lint": "eslint .", "test": "jest --passWithNoTests", + "e2e:ios": "maestro test --device ios .maestro/", + "e2e:android": "maestro test --device android .maestro/", "typecheck": "tsc --noEmit", "build": "echo 'Expo app has no production bundle step; use expo export or EAS when needed.'", "update:preview": "bash -c 'cp .env.local .env.local.bak 2>/dev/null; trap \"mv .env.local.bak .env.local 2>/dev/null\" EXIT; cp .env.production .env.local && touch src/env.ts && eas update --channel preview --message \"preview update\"'" @@ -41,6 +43,7 @@ }, "devDependencies": { "@babel/runtime": "^7.28.0", + "@testing-library/react-native": "^13.2.0", "@types/jest": "^29.5.0", "@types/react": "~19.1.17", "babel-preset-expo": "^54.0.10", diff --git a/apps/mobile/src/components/search/SearchResultCard.tsx b/apps/mobile/src/components/search/SearchResultCard.tsx index 4b8fda422..12f805de6 100644 --- a/apps/mobile/src/components/search/SearchResultCard.tsx +++ b/apps/mobile/src/components/search/SearchResultCard.tsx @@ -48,6 +48,7 @@ export function SearchResultCard({ style={[styles.cardOuter, { opacity, transform: [{ scale }] }]} > onSelect(result.slug)} accessibilityRole="button" accessibilityLabel={`${result.title}: ${result.snippet}`} diff --git a/apps/mobile/src/components/sections/BibleQuotesCarouselRenderer.tsx b/apps/mobile/src/components/sections/BibleQuotesCarouselRenderer.tsx index 6c2d58158..29d343667 100644 --- a/apps/mobile/src/components/sections/BibleQuotesCarouselRenderer.tsx +++ b/apps/mobile/src/components/sections/BibleQuotesCarouselRenderer.tsx @@ -106,6 +106,7 @@ function QuoteCard({ return null return ( [ styles.ctaButton, pressed && styles.ctaButtonPressed, @@ -231,6 +232,7 @@ export function BibleQuotesCarouselRenderer({ )} + return case "text": return case "relatedQuestions": diff --git a/apps/mobile/src/components/sections/CuratedHomeLayout.tsx b/apps/mobile/src/components/sections/CuratedHomeLayout.tsx index b0782d94b..48e00f743 100644 --- a/apps/mobile/src/components/sections/CuratedHomeLayout.tsx +++ b/apps/mobile/src/components/sections/CuratedHomeLayout.tsx @@ -201,6 +201,7 @@ export function CuratedHomeLayout() { > {muteButtonRect != null && ( [ card.surface, { width: cardWidth }, diff --git a/apps/mobile/src/components/sections/NavigationCarouselRenderer.tsx b/apps/mobile/src/components/sections/NavigationCarouselRenderer.tsx index 60f76a74d..7472dff78 100644 --- a/apps/mobile/src/components/sections/NavigationCarouselRenderer.tsx +++ b/apps/mobile/src/components/sections/NavigationCarouselRenderer.tsx @@ -62,6 +62,7 @@ export function NavigationCarouselRenderer({ return ( [ card.base, styles.localCard, diff --git a/apps/mobile/src/components/sections/QuizButtonRenderer.tsx b/apps/mobile/src/components/sections/QuizButtonRenderer.tsx index ba347dcc0..a664049c8 100644 --- a/apps/mobile/src/components/sections/QuizButtonRenderer.tsx +++ b/apps/mobile/src/components/sections/QuizButtonRenderer.tsx @@ -68,6 +68,7 @@ function QuizModal({ url, onClose }: { url: string; onClose: () => void }) { [styles.button, pressed && feedback.pressed]} onPress={() => setModalVisible(true)} accessibilityRole="button" diff --git a/apps/mobile/src/components/sections/RelatedQuestionsRenderer.tsx b/apps/mobile/src/components/sections/RelatedQuestionsRenderer.tsx index aa1651882..b15836fcc 100644 --- a/apps/mobile/src/components/sections/RelatedQuestionsRenderer.tsx +++ b/apps/mobile/src/components/sections/RelatedQuestionsRenderer.tsx @@ -30,10 +30,12 @@ export interface RelatedQuestionsRendererProps { function QuestionRow({ item, + index, isExpanded, onToggle, }: { item: QuestionItem + index: number isExpanded: boolean onToggle: () => void }) { @@ -42,6 +44,7 @@ function QuestionRow({ return ( )} - {questions.map((item) => ( + {questions.map((item, index) => ( handleToggle(item.id)} /> diff --git a/apps/mobile/src/components/sections/VideoCardRenderer.tsx b/apps/mobile/src/components/sections/VideoCardRenderer.tsx index 23e9d13dc..7078df941 100644 --- a/apps/mobile/src/components/sections/VideoCardRenderer.tsx +++ b/apps/mobile/src/components/sections/VideoCardRenderer.tsx @@ -22,11 +22,15 @@ import type { VideoRef } from "../../lib/types" export interface VideoCardRendererProps { section: NormalizedBlock + index?: number } // ── Component ─────────────────────────────────────────────────────────────── -export function VideoCardRenderer({ section }: VideoCardRendererProps) { +export function VideoCardRenderer({ + section, + index = 0, +}: VideoCardRendererProps) { const router = useRouter() const typography = useTypography() @@ -53,6 +57,7 @@ export function VideoCardRenderer({ section }: VideoCardRendererProps) { return ( [ styles.container, pressed && Platform.OS === "ios" && feedback.pressed, diff --git a/apps/mobile/src/components/sections/VideoCarouselRenderer.tsx b/apps/mobile/src/components/sections/VideoCarouselRenderer.tsx index 298a9e8bc..61b4f2d81 100644 --- a/apps/mobile/src/components/sections/VideoCarouselRenderer.tsx +++ b/apps/mobile/src/components/sections/VideoCarouselRenderer.tsx @@ -88,6 +88,7 @@ export function VideoCarouselRenderer({ section }: VideoCarouselRendererProps) { return ( [ card.surface, { width: cardWidth, height: cardHeight }, diff --git a/apps/mobile/src/components/sections/VideoHeroRenderer.tsx b/apps/mobile/src/components/sections/VideoHeroRenderer.tsx index 4bdd88a05..91414aaa1 100644 --- a/apps/mobile/src/components/sections/VideoHeroRenderer.tsx +++ b/apps/mobile/src/components/sections/VideoHeroRenderer.tsx @@ -276,6 +276,7 @@ export function VideoHeroRenderer({ )} {hasCta && ( [ styles.ctaButton, pressed && feedback.pressed, diff --git a/apps/mobile/src/components/ui/HomeHeader.tsx b/apps/mobile/src/components/ui/HomeHeader.tsx index a926762e6..3001f0aa0 100644 --- a/apps/mobile/src/components/ui/HomeHeader.tsx +++ b/apps/mobile/src/components/ui/HomeHeader.tsx @@ -31,6 +31,7 @@ export function HomeHeader({ title, titleOpacity }: HomeHeaderProps) { pointerEvents="none" /> router.navigate("/(tabs)/watch")} @@ -57,6 +58,7 @@ export function HomeHeader({ title, titleOpacity }: HomeHeaderProps) { )} router.navigate("/(tabs)/profile")} diff --git a/apps/tv/.gitignore b/apps/tv/.gitignore index 8b03f2bd7..0fafbaf2d 100644 --- a/apps/tv/.gitignore +++ b/apps/tv/.gitignore @@ -3,3 +3,4 @@ ios/ android/ .env.local node_modules/ +e2e/screenshots/ diff --git a/apps/tv/e2e/adapters/androidtv.ts b/apps/tv/e2e/adapters/androidtv.ts new file mode 100644 index 000000000..dd4d03900 --- /dev/null +++ b/apps/tv/e2e/adapters/androidtv.ts @@ -0,0 +1,109 @@ +import { execSync } from "node:child_process" +import { mkdirSync, existsSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" +import type { TVAdapter, DpadDirection } from "../types" + +/** Maps D-pad directions to Android TV keyevent codes */ +const KEY_EVENTS: Record = { + up: 19, // KEYCODE_DPAD_UP + down: 20, // KEYCODE_DPAD_DOWN + left: 21, // KEYCODE_DPAD_LEFT + right: 22, // KEYCODE_DPAD_RIGHT + select: 23, // KEYCODE_DPAD_CENTER + back: 4, // KEYCODE_BACK +} + +function getAdbPath(): string { + const androidHome = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT + if (androidHome) { + return `${androidHome}/platform-tools/adb` + } + // Fall back to PATH + try { + execSync("which adb", { stdio: "pipe" }) + return "adb" + } catch { + throw new Error( + "adb not found. Set $ANDROID_HOME or $ANDROID_SDK_ROOT.\n" + + 'Typical path: export ANDROID_HOME="$HOME/Library/Android/sdk"\n' + + 'Add to PATH: export PATH="$ANDROID_HOME/platform-tools:$PATH"', + ) + } +} + +export class AndroidTvAdapter implements TVAdapter { + readonly platform = "androidtv" as const + private adb: string + + constructor() { + this.adb = getAdbPath() + } + + async checkAvailability(): Promise { + try { + const output = execSync(`${this.adb} devices`, { + stdio: "pipe", + encoding: "utf-8", + }) + const devices = output + .split("\n") + .filter((line) => line.includes("device") && !line.includes("List")) + if (devices.length === 0) { + throw new Error("No connected devices") + } + } catch (err) { + if (err instanceof Error && err.message === "No connected devices") { + throw new Error( + "No Android TV device/emulator connected.\n" + + "Start one with: $ANDROID_HOME/emulator/emulator -avd ", + ) + } + throw err + } + } + + async sendDpad(direction: DpadDirection): Promise { + const keyEvent = KEY_EVENTS[direction] + execSync(`${this.adb} shell input keyevent ${keyEvent}`, { + stdio: "pipe", + timeout: 5000, + }) + } + + async captureScreenshot(outputPath: string): Promise { + const dir = dirname(outputPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const buffer = execSync(`${this.adb} exec-out screencap -p`, { + maxBuffer: 50 * 1024 * 1024, + timeout: 10000, + }) + writeFileSync(outputPath, buffer) + } + + private validateBundleId(bundleId: string): void { + if (!/^[a-zA-Z0-9._-]+$/.test(bundleId)) { + throw new Error( + `Invalid bundle ID: ${bundleId}. Must match /^[a-zA-Z0-9._-]+$/`, + ) + } + } + + async launchApp(bundleId: string): Promise { + this.validateBundleId(bundleId) + try { + // Use `am start` with LEANBACK_LAUNCHER category for TV apps. + // `monkey` returns non-zero exit codes even on success, so avoid it. + execSync( + `${this.adb} shell am start -a android.intent.action.MAIN -c android.intent.category.LEANBACK_LAUNCHER -n ${bundleId}/.MainActivity`, + { stdio: "pipe", timeout: 15000 }, + ) + } catch { + throw new Error( + `Failed to launch ${bundleId} on Android TV.\n` + + "Ensure the app is installed: EXPO_TV=1 expo run:android", + ) + } + } +} diff --git a/apps/tv/e2e/adapters/tvos.ts b/apps/tv/e2e/adapters/tvos.ts new file mode 100644 index 000000000..bab0bf17e --- /dev/null +++ b/apps/tv/e2e/adapters/tvos.ts @@ -0,0 +1,90 @@ +import { execSync } from "node:child_process" +import { mkdirSync, existsSync } from "node:fs" +import { dirname } from "node:path" +import type { TVAdapter, DpadDirection } from "../types" + +/** + * Maps D-pad directions to USB HID keyboard usage IDs. + * `idb ui key ` sends these through SimulatorBridge (XPC) to the + * simulator's UIFocusEngine — same pathway tvOS uses for external keyboards. + * Verified on Apple TV 4K tvOS 26.1 simulator. + */ +const HID_KEYCODES: Record = { + up: 82, // 0x52 Keyboard UpArrow + down: 81, // 0x51 Keyboard DownArrow + left: 80, // 0x50 Keyboard LeftArrow + right: 79, // 0x4F Keyboard RightArrow + select: 40, // 0x28 Keyboard Return (Enter) + back: 41, // 0x29 Keyboard Escape (Menu button) +} + +export class TvOSAdapter implements TVAdapter { + readonly platform = "tvos" as const + + async checkAvailability(): Promise { + try { + execSync("xcrun simctl list devices booted", { stdio: "pipe" }) + } catch { + throw new Error( + "No booted tvOS Simulator found.\n" + + "Start one with: xcrun simctl boot 'Apple TV'\n" + + "Then open Simulator.app.", + ) + } + + try { + execSync("idb --help", { stdio: "pipe" }) + } catch { + throw new Error( + "idb (Facebook iOS Development Bridge) is not installed.\n" + + "Install: brew tap facebook/fb && brew install idb-companion && pipx install fb-idb\n" + + "Docs: https://fbidb.io/", + ) + } + } + + async sendDpad(direction: DpadDirection): Promise { + const keycode = HID_KEYCODES[direction] + // idb routes through SimulatorBridge (XPC), not macOS window system. + // No frontmost-window requirement, no Accessibility permission, no interference + // with host keyboard input. + execSync(`idb ui key ${keycode}`, { + stdio: "pipe", + timeout: 5000, + }) + } + + async captureScreenshot(outputPath: string): Promise { + const dir = dirname(outputPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + execSync(`xcrun simctl io booted screenshot "${outputPath}"`, { + stdio: "pipe", + timeout: 10000, + }) + } + + private validateBundleId(bundleId: string): void { + if (!/^[a-zA-Z0-9._-]+$/.test(bundleId)) { + throw new Error( + `Invalid bundle ID: ${bundleId}. Must match /^[a-zA-Z0-9._-]+$/`, + ) + } + } + + async launchApp(bundleId: string): Promise { + this.validateBundleId(bundleId) + try { + execSync(`xcrun simctl launch booted "${bundleId}"`, { + stdio: "pipe", + timeout: 15000, + }) + } catch { + throw new Error( + `Failed to launch ${bundleId} on tvOS Simulator.\n` + + "Ensure the app is installed: EXPO_TV=1 expo run:ios", + ) + } + } +} diff --git a/apps/tv/e2e/flows/accessibility-labels.yaml b/apps/tv/e2e/flows/accessibility-labels.yaml new file mode 100644 index 000000000..0a999774a --- /dev/null +++ b/apps/tv/e2e/flows/accessibility-labels.yaml @@ -0,0 +1,11 @@ +name: Accessibility Labels +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - screenshot: home-accessibility + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - screenshot: detail-accessibility diff --git a/apps/tv/e2e/flows/accordion-expand.yaml b/apps/tv/e2e/flows/accordion-expand.yaml new file mode 100644 index 000000000..d9f727e08 --- /dev/null +++ b/apps/tv/e2e/flows/accordion-expand.yaml @@ -0,0 +1,29 @@ +name: Accordion Expand Collapse +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate down to accordion section + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: accordion-collapsed + # Expand first question + - dpad: select + - wait: 500 + - screenshot: accordion-expanded + # Move to next question + - dpad: down + - wait: 300 + - dpad: select + - wait: 500 + - screenshot: second-expanded diff --git a/apps/tv/e2e/flows/accordion-single-open.yaml b/apps/tv/e2e/flows/accordion-single-open.yaml new file mode 100644 index 000000000..34a3c9c07 --- /dev/null +++ b/apps/tv/e2e/flows/accordion-single-open.yaml @@ -0,0 +1,28 @@ +name: Accordion Single Open at a Time +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to accordion + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + # Expand first + - dpad: select + - wait: 500 + - screenshot: first-open + # Move down and expand second (first should close) + - dpad: down + - wait: 300 + - dpad: select + - wait: 500 + - screenshot: second-open-first-closed diff --git a/apps/tv/e2e/flows/carousel-auto-scroll.yaml b/apps/tv/e2e/flows/carousel-auto-scroll.yaml new file mode 100644 index 000000000..857216cc4 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-auto-scroll.yaml @@ -0,0 +1,21 @@ +name: Carousel Auto-Scroll at Edge +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + # Navigate far right to trigger auto-scroll + - dpad: right + - wait: 300 + - dpad: right + - wait: 300 + - dpad: right + - wait: 300 + - dpad: right + - wait: 300 + - screenshot: auto-scrolled diff --git a/apps/tv/e2e/flows/carousel-bible-quotes.yaml b/apps/tv/e2e/flows/carousel-bible-quotes.yaml new file mode 100644 index 000000000..be0e95283 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-bible-quotes.yaml @@ -0,0 +1,21 @@ +name: Bible Quotes Carousel +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to bible quotes section + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: bible-quotes-start + # Navigate right + - dpad: right + - wait: 300 + - screenshot: bible-quotes-right diff --git a/apps/tv/e2e/flows/carousel-focus-exit.yaml b/apps/tv/e2e/flows/carousel-focus-exit.yaml new file mode 100644 index 000000000..2d27d3203 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-focus-exit.yaml @@ -0,0 +1,19 @@ +name: Carousel Focus Exit +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Enter a carousel + - dpad: down + - wait: 300 + - dpad: right + - wait: 300 + - screenshot: in-carousel + # Exit carousel with UP + - dpad: up + - wait: 300 + - screenshot: exited-carousel diff --git a/apps/tv/e2e/flows/carousel-media-collection.yaml b/apps/tv/e2e/flows/carousel-media-collection.yaml new file mode 100644 index 000000000..f1ad93a74 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-media-collection.yaml @@ -0,0 +1,23 @@ +name: Media Collection Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate down to media collection + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: media-collection + # Navigate right + - dpad: right + - wait: 300 + - screenshot: media-right + # Select + - dpad: select + - wait: 1000 + - screenshot: media-selected diff --git a/apps/tv/e2e/flows/carousel-navigation.yaml b/apps/tv/e2e/flows/carousel-navigation.yaml new file mode 100644 index 000000000..f760a38b2 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-navigation.yaml @@ -0,0 +1,19 @@ +name: Navigation Carousel Scroll-to-Section +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to navigation carousel + - screenshot: nav-carousel-visible + # Move right through nav items + - dpad: right + - wait: 300 + - screenshot: nav-right + # Select to scroll-to-section + - dpad: select + - wait: 800 + - screenshot: scrolled-to-section diff --git a/apps/tv/e2e/flows/carousel-video.yaml b/apps/tv/e2e/flows/carousel-video.yaml new file mode 100644 index 000000000..1c1003db4 --- /dev/null +++ b/apps/tv/e2e/flows/carousel-video.yaml @@ -0,0 +1,24 @@ +name: Video Carousel Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to a video carousel section + - dpad: down + - wait: 400 + - screenshot: carousel-start + # Move right through cards + - dpad: right + - wait: 300 + - screenshot: carousel-right-1 + - dpad: right + - wait: 300 + - screenshot: carousel-right-2 + # Select a card to play + - dpad: select + - wait: 1000 + - screenshot: video-playing diff --git a/apps/tv/e2e/flows/container-renderer.yaml b/apps/tv/e2e/flows/container-renderer.yaml new file mode 100644 index 000000000..e3f7c1560 --- /dev/null +++ b/apps/tv/e2e/flows/container-renderer.yaml @@ -0,0 +1,12 @@ +name: Container Renderer Layout +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 400 + - screenshot: container-layout diff --git a/apps/tv/e2e/flows/easter-dates.yaml b/apps/tv/e2e/flows/easter-dates.yaml new file mode 100644 index 000000000..70377f320 --- /dev/null +++ b/apps/tv/e2e/flows/easter-dates.yaml @@ -0,0 +1,17 @@ +name: Easter Dates Renderer +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to easter dates section + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: easter-dates diff --git a/apps/tv/e2e/flows/error-invalid-video.yaml b/apps/tv/e2e/flows/error-invalid-video.yaml new file mode 100644 index 000000000..434cadaf1 --- /dev/null +++ b/apps/tv/e2e/flows/error-invalid-video.yaml @@ -0,0 +1,12 @@ +name: Invalid Video URL Silent Drop +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + - screenshot: video-area diff --git a/apps/tv/e2e/flows/error-network.yaml b/apps/tv/e2e/flows/error-network.yaml new file mode 100644 index 000000000..901175b7d --- /dev/null +++ b/apps/tv/e2e/flows/error-network.yaml @@ -0,0 +1,10 @@ +name: Network Error and Retry +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 5000 + - screenshot: error-state + # Try retry + - dpad: select + - wait: 2000 + - screenshot: after-retry diff --git a/apps/tv/e2e/flows/error-unknown-section.yaml b/apps/tv/e2e/flows/error-unknown-section.yaml new file mode 100644 index 000000000..1808b11d2 --- /dev/null +++ b/apps/tv/e2e/flows/error-unknown-section.yaml @@ -0,0 +1,17 @@ +name: Unknown Section Type Placeholder +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Scroll through all sections — unknown types should render null + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: sections-rendered diff --git a/apps/tv/e2e/flows/experience-back.yaml b/apps/tv/e2e/flows/experience-back.yaml new file mode 100644 index 000000000..7545e1ec5 --- /dev/null +++ b/apps/tv/e2e/flows/experience-back.yaml @@ -0,0 +1,13 @@ +name: Experience Back Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - screenshot: in-experience + - dpad: back + - wait: 500 + - screenshot: back-to-home diff --git a/apps/tv/e2e/flows/experience-detail.yaml b/apps/tv/e2e/flows/experience-detail.yaml new file mode 100644 index 000000000..8eed36a3c --- /dev/null +++ b/apps/tv/e2e/flows/experience-detail.yaml @@ -0,0 +1,22 @@ +name: Experience Detail Sections +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Navigate to experience + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - screenshot: detail-loaded + # Scroll through sections + - dpad: down + - wait: 400 + - screenshot: mid-section + - dpad: down + - wait: 400 + - screenshot: lower-section + # Back to home + - dpad: back + - wait: 500 + - screenshot: home-restored diff --git a/apps/tv/e2e/flows/experience-empty.yaml b/apps/tv/e2e/flows/experience-empty.yaml new file mode 100644 index 000000000..979ef0137 --- /dev/null +++ b/apps/tv/e2e/flows/experience-empty.yaml @@ -0,0 +1,10 @@ +name: Experience Empty Content +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 2000 + - screenshot: empty-state diff --git a/apps/tv/e2e/flows/experience-error.yaml b/apps/tv/e2e/flows/experience-error.yaml new file mode 100644 index 000000000..237f76362 --- /dev/null +++ b/apps/tv/e2e/flows/experience-error.yaml @@ -0,0 +1,10 @@ +name: Experience Error State +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 3000 + - screenshot: experience-state diff --git a/apps/tv/e2e/flows/focus-preferred.yaml b/apps/tv/e2e/flows/focus-preferred.yaml new file mode 100644 index 000000000..dbfa14008 --- /dev/null +++ b/apps/tv/e2e/flows/focus-preferred.yaml @@ -0,0 +1,13 @@ +name: Focus hasTVPreferredFocus One-Shot +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Initial focus should be on Explore button + - screenshot: initial-focus + # Navigate away and back — should not re-steal + - dpad: down + - wait: 300 + - dpad: up + - wait: 300 + - screenshot: focus-not-re-stolen diff --git a/apps/tv/e2e/flows/focus-restoration.yaml b/apps/tv/e2e/flows/focus-restoration.yaml new file mode 100644 index 000000000..83542a108 --- /dev/null +++ b/apps/tv/e2e/flows/focus-restoration.yaml @@ -0,0 +1,15 @@ +name: Focus Restoration After Modal +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - screenshot: before-modal + # Open something + - dpad: select + - wait: 1500 + # Go back + - dpad: back + - wait: 500 + - screenshot: focus-restored diff --git a/apps/tv/e2e/flows/focus-ring.yaml b/apps/tv/e2e/flows/focus-ring.yaml new file mode 100644 index 000000000..ceb906d24 --- /dev/null +++ b/apps/tv/e2e/flows/focus-ring.yaml @@ -0,0 +1,11 @@ +name: Focus Ring Appearance +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - screenshot: focus-ring-card + - dpad: right + - wait: 300 + - screenshot: focus-ring-moved diff --git a/apps/tv/e2e/flows/focus-spatial.yaml b/apps/tv/e2e/flows/focus-spatial.yaml new file mode 100644 index 000000000..db6d5102e --- /dev/null +++ b/apps/tv/e2e/flows/focus-spatial.yaml @@ -0,0 +1,22 @@ +name: Spatial Focus Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Test spatial navigation across different section types + - dpad: down + - wait: 300 + - screenshot: spatial-1 + - dpad: right + - wait: 300 + - screenshot: spatial-2 + - dpad: down + - wait: 300 + - screenshot: spatial-3 + - dpad: left + - wait: 300 + - screenshot: spatial-4 diff --git a/apps/tv/e2e/flows/home-card-select.yaml b/apps/tv/e2e/flows/home-card-select.yaml new file mode 100644 index 000000000..0fdd15c2a --- /dev/null +++ b/apps/tv/e2e/flows/home-card-select.yaml @@ -0,0 +1,16 @@ +name: Home Card Select to Experience +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Navigate to content rail + - dpad: down + - wait: 300 + # Select first card + - dpad: select + - wait: 1500 + - screenshot: experience-detail + # Back to home + - dpad: back + - wait: 500 + - screenshot: home-restored diff --git a/apps/tv/e2e/flows/home-content-rail.yaml b/apps/tv/e2e/flows/home-content-rail.yaml new file mode 100644 index 000000000..b0449c8b2 --- /dev/null +++ b/apps/tv/e2e/flows/home-content-rail.yaml @@ -0,0 +1,20 @@ +name: Home Content Rail Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # D-pad DOWN from Explore to content rail + - dpad: down + - wait: 300 + - screenshot: content-rail-focused + # D-pad RIGHT through cards + - dpad: right + - wait: 300 + - screenshot: second-card + - dpad: right + - wait: 300 + - screenshot: third-card + # D-pad UP back to Explore + - dpad: up + - wait: 300 + - screenshot: explore-refocused diff --git a/apps/tv/e2e/flows/home-error.yaml b/apps/tv/e2e/flows/home-error.yaml new file mode 100644 index 000000000..a8e44f128 --- /dev/null +++ b/apps/tv/e2e/flows/home-error.yaml @@ -0,0 +1,10 @@ +name: Home Error State +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 5000 + - screenshot: state + # Try retry if error visible + - dpad: select + - wait: 2000 + - screenshot: after-retry diff --git a/apps/tv/e2e/flows/home-focus-memory.yaml b/apps/tv/e2e/flows/home-focus-memory.yaml new file mode 100644 index 000000000..acbb88dc4 --- /dev/null +++ b/apps/tv/e2e/flows/home-focus-memory.yaml @@ -0,0 +1,19 @@ +name: Home Focus Memory Per Rail +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Navigate to rail and move right + - dpad: down + - wait: 300 + - dpad: right + - wait: 300 + - dpad: right + - wait: 300 + - screenshot: third-card-focused + # Go up then back down — focus should remember + - dpad: up + - wait: 300 + - dpad: down + - wait: 300 + - screenshot: focus-remembered diff --git a/apps/tv/e2e/flows/home-hero.yaml b/apps/tv/e2e/flows/home-hero.yaml new file mode 100644 index 000000000..d9e330656 --- /dev/null +++ b/apps/tv/e2e/flows/home-hero.yaml @@ -0,0 +1,16 @@ +name: Home Hero Navigation +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - screenshot: hero-visible + # Explore button should be focused by default (hasTVPreferredFocus) + - screenshot: explore-focused + # Press select on Explore + - dpad: select + - wait: 1000 + - screenshot: explore-selected + # Go back + - dpad: back + - wait: 500 + - screenshot: hero-restored diff --git a/apps/tv/e2e/flows/home-loading.yaml b/apps/tv/e2e/flows/home-loading.yaml new file mode 100644 index 000000000..45c1c48eb --- /dev/null +++ b/apps/tv/e2e/flows/home-loading.yaml @@ -0,0 +1,8 @@ +name: Home Loading States +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 500 + - screenshot: loading-state + - wait: 3000 + - screenshot: content-loaded diff --git a/apps/tv/e2e/flows/platform-remote-buttons.yaml b/apps/tv/e2e/flows/platform-remote-buttons.yaml new file mode 100644 index 000000000..0ba844831 --- /dev/null +++ b/apps/tv/e2e/flows/platform-remote-buttons.yaml @@ -0,0 +1,15 @@ +name: Platform Remote Button Mapping +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Navigate into experience + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - screenshot: in-experience + # Back button (Menu on tvOS, Back on Android TV) + - dpad: back + - wait: 500 + - screenshot: back-pressed diff --git a/apps/tv/e2e/flows/platform-scroll-offset.yaml b/apps/tv/e2e/flows/platform-scroll-offset.yaml new file mode 100644 index 000000000..4e6991f15 --- /dev/null +++ b/apps/tv/e2e/flows/platform-scroll-offset.yaml @@ -0,0 +1,17 @@ +name: Platform Scroll Offset Differences +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Scroll down through sections + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: scroll-position diff --git a/apps/tv/e2e/flows/quiz-modal-androidtv.yaml b/apps/tv/e2e/flows/quiz-modal-androidtv.yaml new file mode 100644 index 000000000..66eb529be --- /dev/null +++ b/apps/tv/e2e/flows/quiz-modal-androidtv.yaml @@ -0,0 +1,25 @@ +name: Quiz Modal Android TV WebView +platform: [androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to quiz button + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: quiz-button-focused + # Select to open modal + - dpad: select + - wait: 2000 + - screenshot: webview-modal + # Close modal + - dpad: back + - wait: 500 + - screenshot: modal-closed diff --git a/apps/tv/e2e/flows/quiz-modal-tvos.yaml b/apps/tv/e2e/flows/quiz-modal-tvos.yaml new file mode 100644 index 000000000..dbe010994 --- /dev/null +++ b/apps/tv/e2e/flows/quiz-modal-tvos.yaml @@ -0,0 +1,25 @@ +name: Quiz Modal tvOS QR Code +platform: [tvos] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to quiz button + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: quiz-button-focused + # Select to open modal + - dpad: select + - wait: 1000 + - screenshot: qr-code-modal + # Close modal + - dpad: select + - wait: 500 + - screenshot: modal-closed diff --git a/apps/tv/e2e/flows/section-wrapper.yaml b/apps/tv/e2e/flows/section-wrapper.yaml new file mode 100644 index 000000000..aed536b9d --- /dev/null +++ b/apps/tv/e2e/flows/section-wrapper.yaml @@ -0,0 +1,14 @@ +name: Section Wrapper Children +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: section-wrapper diff --git a/apps/tv/e2e/flows/text-renderer.yaml b/apps/tv/e2e/flows/text-renderer.yaml new file mode 100644 index 000000000..a269917db --- /dev/null +++ b/apps/tv/e2e/flows/text-renderer.yaml @@ -0,0 +1,15 @@ +name: Text Renderer Display +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Navigate to text section + - dpad: down + - wait: 400 + - dpad: down + - wait: 400 + - screenshot: text-section diff --git a/apps/tv/e2e/flows/video-player-controls.yaml b/apps/tv/e2e/flows/video-player-controls.yaml new file mode 100644 index 000000000..27f2acbdd --- /dev/null +++ b/apps/tv/e2e/flows/video-player-controls.yaml @@ -0,0 +1,26 @@ +name: Video Player Controls +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1000 + - screenshot: player-initial + # Play/pause toggle + - dpad: select + - wait: 500 + - screenshot: toggled + # Seek forward + - dpad: right + - wait: 500 + - screenshot: seeked-forward + # Seek backward + - dpad: left + - wait: 500 + - screenshot: seeked-backward diff --git a/apps/tv/e2e/flows/video-player-dismiss.yaml b/apps/tv/e2e/flows/video-player-dismiss.yaml new file mode 100644 index 000000000..8dbbfbebb --- /dev/null +++ b/apps/tv/e2e/flows/video-player-dismiss.yaml @@ -0,0 +1,18 @@ +name: Video Player Dismiss +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1000 + - screenshot: player-open + # Dismiss with back + - dpad: back + - wait: 500 + - screenshot: player-dismissed diff --git a/apps/tv/e2e/flows/video-player-focus-trap.yaml b/apps/tv/e2e/flows/video-player-focus-trap.yaml new file mode 100644 index 000000000..2a2803ae4 --- /dev/null +++ b/apps/tv/e2e/flows/video-player-focus-trap.yaml @@ -0,0 +1,20 @@ +name: Video Player Focus Trap +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1000 + # Try to navigate outside the overlay — should be trapped + - dpad: up + - wait: 300 + - screenshot: focus-trapped-up + - dpad: down + - wait: 300 + - screenshot: focus-trapped-down diff --git a/apps/tv/e2e/flows/video-player-open.yaml b/apps/tv/e2e/flows/video-player-open.yaml new file mode 100644 index 000000000..7a7f6f460 --- /dev/null +++ b/apps/tv/e2e/flows/video-player-open.yaml @@ -0,0 +1,16 @@ +name: Video Player Open +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + # Navigate to experience + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + # Find and select a video card + - dpad: down + - wait: 300 + - dpad: select + - wait: 1000 + - screenshot: player-overlay diff --git a/apps/tv/e2e/flows/video-player-progress.yaml b/apps/tv/e2e/flows/video-player-progress.yaml new file mode 100644 index 000000000..201eb8e88 --- /dev/null +++ b/apps/tv/e2e/flows/video-player-progress.yaml @@ -0,0 +1,16 @@ +name: Video Player Progress Bar +platform: [tvos, androidtv] +steps: + - launch: org.jesusfilm.forgetv + - wait: 3000 + - dpad: down + - wait: 300 + - dpad: select + - wait: 1500 + - dpad: down + - wait: 300 + - dpad: select + - wait: 2000 + - screenshot: progress-initial + - wait: 3000 + - screenshot: progress-updated diff --git a/apps/tv/e2e/runner.test.ts b/apps/tv/e2e/runner.test.ts new file mode 100644 index 000000000..f1678caab --- /dev/null +++ b/apps/tv/e2e/runner.test.ts @@ -0,0 +1,156 @@ +import { parseFlowFile, discoverFlows, executeStep } from "./runner" +import { writeFileSync, mkdirSync, rmSync } from "node:fs" +import { join } from "node:path" +import type { TVAdapter, FlowStep, DpadDirection } from "./types" + +const tmpDir = join(__dirname, "__test_tmp__") + +function createMockAdapter(): TVAdapter & { + calls: Array<{ method: string; args: unknown[] }> +} { + const calls: Array<{ method: string; args: unknown[] }> = [] + return { + platform: "androidtv" as const, + calls, + async sendDpad(direction: DpadDirection) { + calls.push({ method: "sendDpad", args: [direction] }) + }, + async captureScreenshot(outputPath: string) { + calls.push({ method: "captureScreenshot", args: [outputPath] }) + mkdirSync(join(outputPath, ".."), { recursive: true }) + writeFileSync(outputPath, "fake-png") + }, + async launchApp(bundleId: string) { + calls.push({ method: "launchApp", args: [bundleId] }) + }, + async checkAvailability() { + calls.push({ method: "checkAvailability", args: [] }) + }, + } +} + +beforeAll(() => { + mkdirSync(tmpDir, { recursive: true }) +}) + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe("parseFlowFile", () => { + it("parses a valid YAML flow", () => { + const flowPath = join(tmpDir, "test-flow.yaml") + writeFileSync( + flowPath, + ` +name: Test Flow +platform: [tvos, androidtv] +steps: + - dpad: down + - wait: 500 + - screenshot: test-shot + - dpad: select +`, + ) + + const flow = parseFlowFile(flowPath) + expect(flow.name).toBe("Test Flow") + expect(flow.platform).toEqual(["tvos", "androidtv"]) + expect(flow.steps).toHaveLength(4) + }) + + it("throws on invalid flow (missing name)", () => { + const flowPath = join(tmpDir, "invalid-flow.yaml") + writeFileSync( + flowPath, + ` +platform: [tvos] +steps: + - dpad: up +`, + ) + + expect(() => parseFlowFile(flowPath)).toThrow("missing name") + }) +}) + +describe("discoverFlows", () => { + it("discovers YAML files in directory", () => { + const flowDir = join(tmpDir, "flows") + mkdirSync(flowDir, { recursive: true }) + writeFileSync(join(flowDir, "a.yaml"), "") + writeFileSync(join(flowDir, "b.yml"), "") + writeFileSync(join(flowDir, "c.txt"), "") + + const flows = discoverFlows(flowDir) + expect(flows).toHaveLength(2) + expect(flows[0]).toContain("a.yaml") + expect(flows[1]).toContain("b.yml") + }) + + it("returns empty array for missing directory", () => { + const flows = discoverFlows(join(tmpDir, "nonexistent")) + expect(flows).toEqual([]) + }) +}) + +describe("executeStep", () => { + it("executes dpad step", async () => { + const adapter = createMockAdapter() + const step: FlowStep = { dpad: "down" } + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(true) + expect(adapter.calls).toEqual([{ method: "sendDpad", args: ["down"] }]) + }) + + it("executes wait step", async () => { + const adapter = createMockAdapter() + const step: FlowStep = { wait: 10 } + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(true) + expect(adapter.calls).toHaveLength(0) // wait doesn't call adapter + }) + + it("executes screenshot step", async () => { + const adapter = createMockAdapter() + const step: FlowStep = { screenshot: "test-shot" } + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(true) + expect(result.screenshotPath).toContain("test-shot.png") + expect(adapter.calls[0]?.method).toBe("captureScreenshot") + }) + + it("executes launch step", async () => { + const adapter = createMockAdapter() + const step: FlowStep = { launch: "com.test.app" } + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(true) + expect(adapter.calls).toEqual([ + { method: "launchApp", args: ["com.test.app"] }, + ]) + }) + + it("handles unknown step gracefully", async () => { + const adapter = createMockAdapter() + const step = { unknown: "value" } as unknown as FlowStep + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(true) // warns but succeeds + }) + + it("handles adapter errors gracefully", async () => { + const adapter = createMockAdapter() + adapter.sendDpad = async () => { + throw new Error("Simulator not found") + } + const step: FlowStep = { dpad: "up" } + const result = await executeStep(adapter, step, tmpDir, "test") + + expect(result.success).toBe(false) + expect(result.error).toContain("Simulator not found") + }) +}) diff --git a/apps/tv/e2e/runner.ts b/apps/tv/e2e/runner.ts new file mode 100644 index 000000000..cfc051662 --- /dev/null +++ b/apps/tv/e2e/runner.ts @@ -0,0 +1,228 @@ +import { readFileSync, readdirSync } from "node:fs" +import { join, resolve } from "node:path" +import { parseArgs } from "node:util" +import { parse as parseYaml } from "yaml" +import { TvOSAdapter } from "./adapters/tvos" +import { AndroidTvAdapter } from "./adapters/androidtv" +import type { + TVAdapter, + FlowDefinition, + FlowStep, + FlowResult, + StepResult, +} from "./types" +import { DEFAULT_STEP_DELAY_MS } from "./types" + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function createAdapter(platform: "tvos" | "androidtv"): TVAdapter { + return platform === "tvos" ? new TvOSAdapter() : new AndroidTvAdapter() +} + +export function parseFlowFile(filePath: string): FlowDefinition { + const content = readFileSync(filePath, "utf-8") + const parsed = parseYaml(content) as FlowDefinition + if (!parsed.name || !parsed.platform || !parsed.steps) { + throw new Error( + `Invalid flow file ${filePath}: missing name, platform, or steps`, + ) + } + return parsed +} + +export function discoverFlows(flowsDir: string): string[] { + try { + return readdirSync(flowsDir) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .map((f) => join(flowsDir, f)) + .sort() + } catch { + return [] + } +} + +export async function executeStep( + adapter: TVAdapter, + step: FlowStep, + screenshotBaseDir: string, + flowName: string, +): Promise { + try { + if ("dpad" in step) { + await adapter.sendDpad(step.dpad) + return { step, success: true } + } + if ("wait" in step) { + await sleep(step.wait) + return { step, success: true } + } + if ("delay" in step) { + await sleep(step.delay) + return { step, success: true } + } + if ("screenshot" in step) { + const path = join( + screenshotBaseDir, + adapter.platform, + flowName, + `${step.screenshot}.png`, + ) + await adapter.captureScreenshot(path) + return { step, success: true, screenshotPath: path } + } + if ("launch" in step) { + await adapter.launchApp(step.launch) + return { step, success: true } + } + // Unknown step — warn and skip + console.warn(` [warn] Unknown step type: ${JSON.stringify(step)}`) + return { step, success: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { step, success: false, error: message } + } +} + +export async function runFlow( + flow: FlowDefinition, + adapter: TVAdapter, + screenshotBaseDir: string, +): Promise { + const start = Date.now() + const stepResults: StepResult[] = [] + + console.log(` Running: ${flow.name} on ${adapter.platform}`) + + for (const step of flow.steps) { + const result = await executeStep( + adapter, + step, + screenshotBaseDir, + flow.name + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(), + ) + stepResults.push(result) + + if (!result.success) { + console.error(` [FAIL] ${JSON.stringify(step)}: ${result.error}`) + } + + // Default delay between D-pad steps + if ("dpad" in step) { + await sleep(DEFAULT_STEP_DELAY_MS) + } + } + + const duration = Date.now() - start + const success = stepResults.every((r) => r.success) + + console.log( + ` ${success ? "PASS" : "FAIL"}: ${flow.name} (${duration}ms, ${stepResults.length} steps)`, + ) + + return { + name: flow.name, + platform: adapter.platform, + steps: stepResults, + success, + duration, + } +} + +async function main() { + const { values } = parseArgs({ + options: { + platform: { type: "string", short: "p" }, + flows: { type: "string", short: "f", default: "e2e/flows" }, + screenshots: { + type: "string", + short: "s", + default: "e2e/screenshots", + }, + }, + }) + + const platform = values.platform as "tvos" | "androidtv" | undefined + if (!platform || !["tvos", "androidtv"].includes(platform)) { + console.error("Usage: tsx e2e/runner.ts --platform ") + process.exit(1) + } + + const flowsDir = resolve(values.flows!) + const screenshotDir = resolve(values.screenshots!) + + console.log(`\nTV E2E Runner — ${platform}`) + console.log(`Flows: ${flowsDir}`) + console.log(`Screenshots: ${screenshotDir}\n`) + + const adapter = createAdapter(platform) + + // Check adapter availability + try { + await adapter.checkAvailability() + } catch (err) { + console.error(`[ERROR] ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + + // Discover and run flows + const flowFiles = discoverFlows(flowsDir) + if (flowFiles.length === 0) { + console.log("No flow files found.") + process.exit(0) + } + + console.log(`Found ${flowFiles.length} flow(s)\n`) + + const results: FlowResult[] = [] + for (const file of flowFiles) { + const flow = parseFlowFile(file) + + // Skip flows not targeting this platform + if (!flow.platform.includes(platform)) { + console.log(` Skipped: ${flow.name} (not targeting ${platform})`) + continue + } + + const result = await runFlow(flow, adapter, screenshotDir) + results.push(result) + } + + // Summary + const passed = results.filter((r) => r.success).length + const failed = results.filter((r) => !r.success).length + const totalDuration = results.reduce((sum, r) => sum + r.duration, 0) + + console.log(`\n--- Summary ---`) + console.log(`Platform: ${platform}`) + console.log( + `Results: ${passed} passed, ${failed} failed, ${results.length} total`, + ) + console.log(`Duration: ${(totalDuration / 1000).toFixed(1)}s`) + + if (failed > 0) { + console.log(`\nFailed flows:`) + for (const r of results.filter((r) => !r.success)) { + console.log(` - ${r.name}`) + for (const s of r.steps.filter((s) => !s.success)) { + console.log(` ${JSON.stringify(s.step)}: ${s.error}`) + } + } + process.exit(1) + } +} + +// Only run main() when executed directly, not when imported by tests +const isDirectExecution = + typeof require !== "undefined" && require.main === module + +if (isDirectExecution) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/apps/tv/e2e/types.ts b/apps/tv/e2e/types.ts new file mode 100644 index 000000000..4aa4435df --- /dev/null +++ b/apps/tv/e2e/types.ts @@ -0,0 +1,47 @@ +export type DpadDirection = "up" | "down" | "left" | "right" | "select" | "back" + +export type FlowStep = + | { dpad: DpadDirection } + | { wait: number } + | { screenshot: string } + | { launch: string } + | { delay: number } + +export type FlowDefinition = { + name: string + platform: Array<"tvos" | "androidtv"> + steps: FlowStep[] +} + +export type StepResult = { + step: FlowStep + success: boolean + error?: string + screenshotPath?: string +} + +export type FlowResult = { + name: string + platform: "tvos" | "androidtv" + steps: StepResult[] + success: boolean + duration: number +} + +export interface TVAdapter { + readonly platform: "tvos" | "androidtv" + + /** Send a D-pad direction command */ + sendDpad(direction: DpadDirection): Promise + + /** Capture a screenshot and save to the given path */ + captureScreenshot(outputPath: string): Promise + + /** Launch an app by bundle ID */ + launchApp(bundleId: string): Promise + + /** Check if the adapter's requirements are met */ + checkAvailability(): Promise +} + +export const DEFAULT_STEP_DELAY_MS = 200 diff --git a/apps/tv/package.json b/apps/tv/package.json index bd23ed1a3..b0b8b63d3 100644 --- a/apps/tv/package.json +++ b/apps/tv/package.json @@ -10,6 +10,8 @@ "lint": "eslint .", "test": "jest --passWithNoTests", "typecheck": "tsc --noEmit", + "e2e:tvos": "tsx e2e/runner.ts --platform tvos", + "e2e:androidtv": "tsx e2e/runner.ts --platform androidtv", "build": "echo 'Expo TV app has no production bundle step; use EAS when needed.'" }, "dependencies": { @@ -37,6 +39,7 @@ }, "devDependencies": { "@babel/runtime": "^7.28.0", + "@testing-library/react-native": "^13.2.0", "@types/jest": "^29.5.0", "@types/react": "~19.1.17", "babel-preset-expo": "^54.0.10", @@ -44,12 +47,14 @@ "eslint-config-expo": "~10.0.0", "jest": "^29.7.0", "jest-expo": "~54.0.0", - "typescript": "~5.9.2" + "tsx": "^4.21.0", + "typescript": "~5.9.2", + "yaml": "^2.8.2" }, "jest": { "preset": "jest-expo", "transformIgnorePatterns": [ - "/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|@expo|react-navigation|@react-navigation|@hebcal|@t3-oss|zod))" + "/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|@expo|react-navigation|@react-navigation|@hebcal|@t3-oss|zod|yaml))" ] }, "private": true diff --git a/apps/tv/tsconfig.json b/apps/tv/tsconfig.json index ec3dc16cf..d8b2d84a4 100644 --- a/apps/tv/tsconfig.json +++ b/apps/tv/tsconfig.json @@ -9,5 +9,6 @@ "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"] } }, - "include": ["**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["e2e/**"] } diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..5c4ffa21f --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,2 @@ +playwright-report/ +test-results/ diff --git a/apps/web/e2e/.gitignore b/apps/web/e2e/.gitignore new file mode 100644 index 000000000..73b1c19a2 --- /dev/null +++ b/apps/web/e2e/.gitignore @@ -0,0 +1,3 @@ +screenshots/ +test-results/ +playwright-report/ diff --git a/apps/web/e2e/flows/.gitkeep b/apps/web/e2e/flows/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/e2e/flows/advent-countdown.spec.ts b/apps/web/e2e/flows/advent-countdown.spec.ts new file mode 100644 index 000000000..a210fde25 --- /dev/null +++ b/apps/web/e2e/flows/advent-countdown.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/advent-countdown" + +test.describe("Advent Countdown", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.6), + ) + await page.waitForTimeout(500) + }) + + test("expanded by default on desktop (>=640px)", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/desktop-expanded.png` }) + }) + + test("collapsed by default on mobile (<640px)", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/mobile-collapsed.png` }) + }) + + test("toggle expand/collapse on click", async ({ page }) => { + const toggle = page + .locator( + '[data-testid="advent-toggle"], [class*="advent"] button, [class*="countdown"] button', + ) + .first() + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await toggle.click() + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/toggled.png` }) + } + }) + + test("responsive resize behavior", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/resize-desktop.png` }) + await page.setViewportSize({ width: 375, height: 667 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/resize-mobile.png` }) + }) + + test("days count display", async ({ page }) => { + const daysEl = page + .locator( + '[data-testid="advent-days"], [class*="advent"] [class*="days"], [class*="countdown"] span', + ) + .first() + if (await daysEl.isVisible({ timeout: 3000 }).catch(() => false)) { + const text = await daysEl.textContent() + expect(text).toMatch(/\d+/) + } + await page.screenshot({ path: `${screenshotDir}/days-count.png` }) + }) + + test("singular 1 day vs plural X days label", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/day-label.png` }) + }) + + test("scripture text and reference display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/scripture.png` }) + }) + + test("year placeholder {year} replacement", async ({ page }) => { + const currentYear = new Date().getFullYear().toString() + const yearText = page.locator(`text=${currentYear}`) + if ( + await yearText + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await expect(yearText.first()).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/year-placeholder.png` }) + }) + + test("arrow rotation animation (180deg)", async ({ page }) => { + const toggle = page + .locator('[data-testid="advent-toggle"], [class*="advent"] button') + .first() + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.screenshot({ path: `${screenshotDir}/arrow-before.png` }) + await toggle.click() + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/arrow-after.png` }) + } + }) + + test("aria-expanded accessibility", async ({ page }) => { + const toggle = page + .locator( + '[data-testid="advent-toggle"], [class*="advent"] button, [aria-expanded]', + ) + .first() + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + const expanded = await toggle.getAttribute("aria-expanded") + expect(expanded).toBeDefined() + } + await page.screenshot({ path: `${screenshotDir}/aria-expanded.png` }) + }) + + test("multiple days calculation accuracy", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/days-accuracy.png` }) + }) + + test("Christmas Day state — Merry Christmas", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/christmas-state.png` }) + }) +}) diff --git a/apps/web/e2e/flows/animations.spec.ts b/apps/web/e2e/flows/animations.spec.ts new file mode 100644 index 000000000..3e408c3a3 --- /dev/null +++ b/apps/web/e2e/flows/animations.spec.ts @@ -0,0 +1,132 @@ +import { test } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/animations" + +test.describe("Animations", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("search overlay fade in/out (0.2s)", async ({ page }) => { + const searchToggle = page + .locator('[data-testid="search-toggle"], header button') + .first() + if (await searchToggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await searchToggle.click() + await page.waitForTimeout(50) + await page.screenshot({ path: `${screenshotDir}/overlay-fade-in.png` }) + await page.waitForTimeout(300) + await page.keyboard.press("Escape") + await page.waitForTimeout(50) + await page.screenshot({ path: `${screenshotDir}/overlay-fade-out.png` }) + } + }) + + test("card enter/exit animations (staggered delays)", async ({ page }) => { + const searchToggle = page + .locator('[data-testid="search-toggle"], header button') + .first() + if (await searchToggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await searchToggle.click() + await page.waitForTimeout(400) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(800) + await page.screenshot({ path: `${screenshotDir}/card-enter.png` }) + } + }) + + test("hover scale (1.02) on video cards", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 300)) + await page.waitForTimeout(500) + const card = page + .locator('[class*="card"], [class*="video-card"], a[href*="/"]') + .first() + if (await card.isVisible({ timeout: 3000 }).catch(() => false)) { + await card.hover() + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/hover-scale.png` }) + }) + + test("image zoom 105% on hover (MediaCollection)", async ({ page }) => { + const item = page + .locator( + '[data-testid="media-collection-item"], [class*="media-collection"] a', + ) + .first() + if (await item.isVisible({ timeout: 3000 }).catch(() => false)) { + await item.hover() + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/image-zoom.png` }) + }) + + test("arrow rotation (accordion)", async ({ page }) => { + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.7), + ) + await page.waitForTimeout(500) + const trigger = page + .locator('[data-testid="accordion-trigger"], [class*="accordion"] button') + .first() + if (await trigger.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.screenshot({ path: `${screenshotDir}/arrow-collapsed.png` }) + await trigger.click() + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/arrow-expanded.png` }) + } + }) + + test("mesh gradient animation (quiz button)", async ({ page }) => { + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.5), + ) + await page.waitForTimeout(500) + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.screenshot({ path: `${screenshotDir}/mesh-gradient-1.png` }) + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/mesh-gradient-2.png` }) + } + }) + + test("accordion height animation", async ({ page }) => { + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.7), + ) + await page.waitForTimeout(500) + const trigger = page + .locator('[data-testid="accordion-trigger"], [class*="accordion"] button') + .first() + if (await trigger.isVisible({ timeout: 3000 }).catch(() => false)) { + await trigger.click() + await page.waitForTimeout(100) + await page.screenshot({ path: `${screenshotDir}/height-animating.png` }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${screenshotDir}/height-done.png` }) + } + }) + + test("loading spinner rotation", async ({ page }) => { + await page.route( + "**/graphql*", + (route) => + new Promise((resolve) => + setTimeout(() => resolve(route.abort()), 5000), + ), + ) + await page.goto("/search?q=test") + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/spinner.png` }) + }) +}) diff --git a/apps/web/e2e/flows/bible-quotes.spec.ts b/apps/web/e2e/flows/bible-quotes.spec.ts new file mode 100644 index 000000000..b354f30d5 --- /dev/null +++ b/apps/web/e2e/flows/bible-quotes.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/bible-quotes" + +test.describe("Bible Quotes Carousel", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight / 2), + ) + await page.waitForTimeout(500) + }) + + test("carousel horizontal navigation", async ({ page }) => { + const carousel = page + .locator( + '[data-testid="bible-quotes"], [class*="bible-quote"], [class*="quote-carousel"]', + ) + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + } + } + await page.screenshot({ path: `${screenshotDir}/horizontal-nav.png` }) + }) + + test("quote card display — reference, text, image, bg color", async ({ + page, + }) => { + await page.screenshot({ path: `${screenshotDir}/quote-card.png` }) + }) + + test("free resource card with CTA button", async ({ page }) => { + const cta = page + .locator( + '[data-testid="resource-cta"], [class*="resource"] a, [class*="quote"] a[target]', + ) + .first() + if (await cta.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(cta).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/resource-cta.png` }) + }) + + test("resource CTA click opens new tab", async ({ page }) => { + const cta = page + .locator( + '[data-testid="resource-cta"], [class*="resource"] a[target="_blank"]', + ) + .first() + if (await cta.isVisible({ timeout: 3000 }).catch(() => false)) { + const [newPage] = await Promise.all([ + page.waitForEvent("popup", { timeout: 3000 }).catch(() => null), + cta.click(), + ]) + if (newPage) { + await newPage.close() + } + } + await page.screenshot({ path: `${screenshotDir}/cta-new-tab.png` }) + }) + + test("share button uses native share or clipboard fallback", async ({ + page, + }) => { + const shareBtn = page + .locator( + '[data-testid="share-button"], button:has-text("Share"), [aria-label*="hare"]', + ) + .first() + if (await shareBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await shareBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/share.png` }) + }) + + test("share URL format includes utm_source=share", async ({ page }) => { + const shareBtn = page + .locator( + '[data-testid="share-button"], button:has-text("Share"), [aria-label*="hare"]', + ) + .first() + if (await shareBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + const clipboardText = await page.evaluate(async () => { + try { + return await navigator.clipboard.readText() + } catch { + return "" + } + }) + if (clipboardText) { + expect(clipboardText).toContain("utm_source=share") + } + } + await page.screenshot({ path: `${screenshotDir}/share-url.png` }) + }) + + test("image mask gradient display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/image-mask.png` }) + }) + + test("background color on quote cards", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/bg-color.png` }) + }) + + test("carousel drag behavior", async ({ page }) => { + const carousel = page + .locator( + '[data-testid="bible-quotes"], [class*="bible-quote"], [class*="quote-carousel"]', + ) + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/drag-behavior.png` }) + }) +}) diff --git a/apps/web/e2e/flows/carousel-video-player.spec.ts b/apps/web/e2e/flows/carousel-video-player.spec.ts new file mode 100644 index 000000000..7b44d8935 --- /dev/null +++ b/apps/web/e2e/flows/carousel-video-player.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/carousel-video-player" + +test.describe("Carousel Video Player", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("thumbnail card selection updates main player", async ({ page }) => { + const thumbnail = page + .locator( + '[data-testid="carousel-thumbnail"], .carousel-thumbnail, .embla__slide', + ) + .first() + if (await thumbnail.isVisible({ timeout: 3000 }).catch(() => false)) { + await thumbnail.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/thumbnail-select.png` }) + }) + + test("thumbnail keyboard Enter selection", async ({ page }) => { + const thumbnail = page + .locator( + '[data-testid="carousel-thumbnail"], .carousel-thumbnail, .embla__slide', + ) + .first() + if (await thumbnail.isVisible({ timeout: 3000 }).catch(() => false)) { + await thumbnail.focus() + await page.keyboard.press("Enter") + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/thumbnail-keyboard.png` }) + }) + + test("carousel horizontal drag/swipe", async ({ page }) => { + const carousel = page + .locator('[data-testid="video-carousel"], .embla, [class*="carousel"]') + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/carousel-drag.png` }) + }) + + test("main player controls — play/pause/mute/seek/fullscreen", async ({ + page, + }) => { + const player = page.locator("video, [data-testid='video-player']").first() + if (await player.isVisible({ timeout: 3000 }).catch(() => false)) { + await player.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/player-controls.png` }) + }) + + test("play on video change — auto-play when switching", async ({ page }) => { + const thumbnails = page.locator( + '[data-testid="carousel-thumbnail"], .carousel-thumbnail, .embla__slide', + ) + if ( + await thumbnails + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await thumbnails.first().click() + await page.waitForTimeout(500) + if ( + await thumbnails + .nth(1) + .isVisible({ timeout: 1000 }) + .catch(() => false) + ) { + await thumbnails.nth(1).click() + await page.waitForTimeout(1000) + } + } + await page.screenshot({ path: `${screenshotDir}/auto-play-switch.png` }) + }) + + test("title, subtitle, description display", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 300)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/title-subtitle.png` }) + }) + + test("description first-4-words bold formatting", async ({ page }) => { + const description = page + .locator('[data-testid="video-description"], [class*="description"]') + .first() + if (await description.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(description).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/description-bold.png` }) + }) + + test("desktop navigation arrows on hover", async ({ page }) => { + const carousel = page + .locator('[data-testid="video-carousel"], .embla, [class*="carousel"]') + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + await carousel.hover() + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/nav-arrows.png` }) + }) + + test("hover play indicator on thumbnail", async ({ page }) => { + const thumbnail = page + .locator( + '[data-testid="carousel-thumbnail"], .carousel-thumbnail, .embla__slide', + ) + .first() + if (await thumbnail.isVisible({ timeout: 3000 }).catch(() => false)) { + await thumbnail.hover() + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/hover-play-indicator.png` }) + }) +}) diff --git a/apps/web/e2e/flows/easter-dates.spec.ts b/apps/web/e2e/flows/easter-dates.spec.ts new file mode 100644 index 000000000..28e1b74c3 --- /dev/null +++ b/apps/web/e2e/flows/easter-dates.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/easter-dates" + +test.describe("Easter Dates", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.6), + ) + await page.waitForTimeout(500) + }) + + test("expanded on desktop, collapsed on mobile", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/desktop-expanded.png` }) + await page.setViewportSize({ width: 375, height: 667 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/mobile-collapsed.png` }) + }) + + test("toggle expand/collapse", async ({ page }) => { + const toggle = page + .locator('[data-testid="easter-toggle"], [class*="easter"] button') + .first() + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await toggle.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/toggled.png` }) + }) + + test("Western Easter date display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/western-easter.png` }) + }) + + test("Orthodox Easter date display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/orthodox-easter.png` }) + }) + + test("Passover date calculation (Hebrew calendar)", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/passover.png` }) + }) + + test("date format — Day Month Date Year", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/date-format.png` }) + }) + + test("locale-aware date formatting", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/locale-dates.png` }) + }) + + test("current year calculation", async ({ page }) => { + const year = new Date().getFullYear().toString() + const yearText = page.locator(`text=${year}`) + if ( + await yearText + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await expect(yearText.first()).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/current-year.png` }) + }) + + test("year placeholder in title", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/year-title.png` }) + }) + + test("responsive media query behavior", async ({ page }) => { + await page.setViewportSize({ width: 639, height: 667 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/narrow.png` }) + await page.setViewportSize({ width: 640, height: 667 }) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/wide.png` }) + }) +}) diff --git a/apps/web/e2e/flows/error-states.spec.ts b/apps/web/e2e/flows/error-states.spec.ts new file mode 100644 index 000000000..9c4238cff --- /dev/null +++ b/apps/web/e2e/flows/error-states.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/error-states" + +test.describe("Error States", () => { + test("GraphQL connection error", async ({ page }) => { + await page.route("**/graphql*", (route) => route.abort("connectionrefused")) + await page.goto("/") + await page.waitForTimeout(3000) + await page.screenshot({ path: `${screenshotDir}/graphql-error.png` }) + }) + + test("missing credentials (401) shows friendly message", async ({ page }) => { + await page.route("**/graphql*", (route) => + route.fulfill({ + status: 401, + body: JSON.stringify({ error: "Unauthorized" }), + }), + ) + await page.goto("/") + await page.waitForTimeout(3000) + await page.screenshot({ path: `${screenshotDir}/unauthorized.png` }) + }) + + test("null blocks filtered from rendering", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const nullBlock = page.locator('[data-testid="null-block"]') + expect(await nullBlock.count()).toBe(0) + await page.screenshot({ path: `${screenshotDir}/null-blocks.png` }) + }) + + test("missing video URL — section returns null", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/missing-video-url.png` }) + }) + + test("invalid locale param falls back to DEFAULT_LOCALE", async ({ + page, + }) => { + await page.goto("/some-slug/xx-invalid-locale") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/invalid-locale.png` }) + }) + + test("empty search results", async ({ page }) => { + await page.goto("/search?q=xyznonexistentquery12345") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/empty-search.png` }) + }) + + test("search rate limited (retryAfterSeconds)", async ({ page }) => { + await page.route("**/graphql*", (route) => + route.fulfill({ + status: 429, + body: JSON.stringify({ + errors: [ + { message: "Rate limited", extensions: { retryAfterSeconds: 5 } }, + ], + }), + }), + ) + await page.goto("/search?q=test") + await page.waitForTimeout(2000) + await page.screenshot({ path: `${screenshotDir}/rate-limited.png` }) + }) + + test("malformed search response", async ({ page }) => { + await page.route("**/graphql*", (route) => + route.fulfill({ status: 200, body: "not json" }), + ) + await page.goto("/search?q=test") + await page.waitForTimeout(2000) + await page.screenshot({ path: `${screenshotDir}/malformed-response.png` }) + }) + + test("long query truncation", async ({ page }) => { + const longQ = "a".repeat(250) + await page.goto(`/search?q=${longQ}`) + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/long-query.png` }) + }) + + test("special characters in search", async ({ page }) => { + await page.goto('/search?q=') + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/special-chars.png` }) + }) + + test("missing routeVideo context", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/missing-route-video.png` }) + }) +}) diff --git a/apps/web/e2e/flows/keyboard-navigation.spec.ts b/apps/web/e2e/flows/keyboard-navigation.spec.ts new file mode 100644 index 000000000..76f8aab6f --- /dev/null +++ b/apps/web/e2e/flows/keyboard-navigation.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/keyboard-navigation" + +test.describe("Keyboard Navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("tab forward through interactive elements", async ({ page }) => { + for (let i = 0; i < 5; i++) { + await page.keyboard.press("Tab") + await page.waitForTimeout(100) + } + await page.screenshot({ path: `${screenshotDir}/tab-forward.png` }) + }) + + test("shift+tab backward", async ({ page }) => { + for (let i = 0; i < 5; i++) { + await page.keyboard.press("Tab") + } + for (let i = 0; i < 3; i++) { + await page.keyboard.press("Shift+Tab") + await page.waitForTimeout(100) + } + await page.screenshot({ path: `${screenshotDir}/shift-tab-backward.png` }) + }) + + test("enter key button activation", async ({ page }) => { + const button = page.locator("button, a[href]").first() + if (await button.isVisible({ timeout: 3000 }).catch(() => false)) { + await button.focus() + await page.keyboard.press("Enter") + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/enter-activation.png` }) + }) + + test("space key button activation", async ({ page }) => { + const button = page.locator("button").first() + if (await button.isVisible({ timeout: 3000 }).catch(() => false)) { + await button.focus() + await page.keyboard.press("Space") + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/space-activation.png` }) + }) + + test("arrow keys in carousels", async ({ page }) => { + const carousel = page + .locator('[class*="carousel"], .embla, [role="listbox"]') + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + await carousel.focus() + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(300) + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(300) + await page.keyboard.press("ArrowLeft") + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/arrow-carousel.png` }) + }) + + test("escape key closes modals and overlays", async ({ page }) => { + const searchToggle = page + .locator('[data-testid="search-toggle"], header button') + .first() + if (await searchToggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await searchToggle.click() + await page.waitForTimeout(300) + await page.keyboard.press("Escape") + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/escape-close.png` }) + }) + + test("focus visible outlines (focus-visible styles)", async ({ page }) => { + await page.keyboard.press("Tab") + await page.waitForTimeout(100) + const focused = page.locator(":focus-visible") + if (await focused.isVisible({ timeout: 1000 }).catch(() => false)) { + const outline = await focused.evaluate( + (el) => getComputedStyle(el).outlineStyle, + ) + expect(outline).not.toBe("none") + } + await page.screenshot({ path: `${screenshotDir}/focus-visible.png` }) + }) + + test("skip to content link", async ({ page }) => { + await page.keyboard.press("Tab") + const skipLink = page.locator( + 'a:has-text("Skip to content"), a:has-text("Skip to main")', + ) + if (await skipLink.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(skipLink).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/skip-link.png` }) + }) +}) diff --git a/apps/web/e2e/flows/media-collection.spec.ts b/apps/web/e2e/flows/media-collection.spec.ts new file mode 100644 index 000000000..f6c081e3d --- /dev/null +++ b/apps/web/e2e/flows/media-collection.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/media-collection" + +test.describe("Media Collection", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("item hover changes background image", async ({ page }) => { + const item = page + .locator( + '[data-testid="media-collection-item"], [class*="media-collection"] a, [class*="collection-item"]', + ) + .first() + if (await item.isVisible({ timeout: 3000 }).catch(() => false)) { + await item.hover() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/hover-bg.png` }) + }) + + test("image scale 105% on hover", async ({ page }) => { + const item = page + .locator( + '[data-testid="media-collection-item"], [class*="media-collection"] a, [class*="collection-item"]', + ) + .first() + if (await item.isVisible({ timeout: 3000 }).catch(() => false)) { + await item.hover() + await page.waitForTimeout(300) + const transform = await item + .locator("img") + .first() + .evaluate((el) => getComputedStyle(el).transform) + if (transform && transform !== "none") { + expect(transform).toContain("matrix") + } + } + await page.screenshot({ path: `${screenshotDir}/image-scale.png` }) + }) + + test("item click navigates to /watch/[slug]", async ({ page }) => { + const item = page + .locator( + '[data-testid="media-collection-item"] a, [class*="media-collection"] a[href*="/"]', + ) + .first() + if (await item.isVisible({ timeout: 3000 }).catch(() => false)) { + const href = await item.getAttribute("href") + if (href) { + await item.click() + await page.waitForTimeout(1000) + } + } + await page.screenshot({ path: `${screenshotDir}/click-navigate.png` }) + }) + + test("item without slug is not clickable", async ({ page }) => { + const nonClickable = page + .locator( + '[data-testid="media-collection-item"]:not(a), [class*="collection-item"] div[class*="pointer-events-none"]', + ) + .first() + if (await nonClickable.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(nonClickable).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/no-slug.png` }) + }) + + test("carousel drag", async ({ page }) => { + const carousel = page + .locator('[data-testid="media-collection"], [class*="media-collection"]') + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + } + } + await page.screenshot({ path: `${screenshotDir}/carousel-drag.png` }) + }) + + test("CTA Watch button click", async ({ page }) => { + const cta = page + .locator('button:has-text("Watch"), a:has-text("Watch")') + .first() + if (await cta.isVisible({ timeout: 3000 }).catch(() => false)) { + await cta.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/cta-watch.png` }) + }) + + test("title, subtitle, description display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/title-subtitle-desc.png` }) + }) + + test("footer text display", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/footer.png` }) + }) + + test("collection size badge (top-right)", async ({ page }) => { + const badge = page + .locator('[data-testid="collection-size"], [class*="badge"]') + .first() + if (await badge.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(badge).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/size-badge.png` }) + }) + + test("label display (lowercase formatted)", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/label.png` }) + }) +}) diff --git a/apps/web/e2e/flows/navigation-carousel.spec.ts b/apps/web/e2e/flows/navigation-carousel.spec.ts new file mode 100644 index 000000000..3522f5269 --- /dev/null +++ b/apps/web/e2e/flows/navigation-carousel.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/navigation-carousel" + +test.describe("Navigation Carousel", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("item click scrolls to data-section-key", async ({ page }) => { + const navItem = page + .locator( + '[data-testid="nav-carousel-item"], [class*="nav-carousel"] a, [class*="navigation"] button', + ) + .first() + if (await navItem.isVisible({ timeout: 3000 }).catch(() => false)) { + await navItem.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/item-scroll.png` }) + }) + + test("item keyboard activation (Enter/Space)", async ({ page }) => { + const navItem = page + .locator( + '[data-testid="nav-carousel-item"], [class*="nav-carousel"] a, [class*="navigation"] button', + ) + .first() + if (await navItem.isVisible({ timeout: 3000 }).catch(() => false)) { + await navItem.focus() + await page.keyboard.press("Enter") + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/keyboard-activation.png` }) + }) + + test("carousel drag/swipe", async ({ page }) => { + const carousel = page + .locator( + '[data-testid="nav-carousel"], [class*="nav-carousel"], [class*="navigation-carousel"]', + ) + .first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/drag.png` }) + }) + + test("item image display with mask gradient", async ({ page }) => { + const img = page + .locator( + '[data-testid="nav-carousel-item"] img, [class*="nav-carousel"] img', + ) + .first() + if (await img.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(img).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/item-image.png` }) + }) + + test("item title and category labels", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/title-category.png` }) + }) + + test("first item image optimization (next/image)", async ({ page }) => { + const firstImg = page + .locator( + '[data-testid="nav-carousel-item"] img, [class*="nav-carousel"] img', + ) + .first() + if (await firstImg.isVisible({ timeout: 3000 }).catch(() => false)) { + const srcset = await firstImg.getAttribute("srcset") + if (srcset) { + expect(srcset.length).toBeGreaterThan(0) + } + } + await page.screenshot({ path: `${screenshotDir}/image-optimization.png` }) + }) + + test("background color support", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/bg-color.png` }) + }) + + test("smooth scroll behavior verification", async ({ page }) => { + const navItem = page + .locator( + '[data-testid="nav-carousel-item"], [class*="nav-carousel"] a, [class*="navigation"] button', + ) + .first() + if (await navItem.isVisible({ timeout: 3000 }).catch(() => false)) { + const scrollBefore = await page.evaluate(() => window.scrollY) + await navItem.click() + await page.waitForTimeout(200) + const scrollDuring = await page.evaluate(() => window.scrollY) + await page.waitForTimeout(800) + const scrollAfter = await page.evaluate(() => window.scrollY) + if (scrollBefore !== scrollAfter) { + expect(scrollDuring).not.toBe(scrollAfter) + } + } + await page.screenshot({ path: `${screenshotDir}/smooth-scroll.png` }) + }) +}) diff --git a/apps/web/e2e/flows/navigation-header.spec.ts b/apps/web/e2e/flows/navigation-header.spec.ts new file mode 100644 index 000000000..876236a50 --- /dev/null +++ b/apps/web/e2e/flows/navigation-header.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/navigation-header" + +test.describe("Navigation & Header", () => { + test("logo click navigates to home", async ({ page }) => { + await page.goto("/search") + await page.waitForLoadState("networkidle") + const logo = page.locator('header a[href="/"]').first() + await logo.click() + await expect(page).toHaveURL("/") + await page.screenshot({ path: `${screenshotDir}/logo-home.png` }) + }) + + test("search toggle opens overlay with animation", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const searchToggle = page + .locator('[data-testid="search-toggle"]') + .or(page.locator('button:has([data-testid="search-icon"])')) + await searchToggle.first().click() + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/search-overlay-open.png` }) + }) + + test("search overlay closes via X button", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const searchToggle = page + .locator('[data-testid="search-toggle"]') + .or(page.locator("header button").first()) + await searchToggle.first().click() + await page.waitForTimeout(300) + const closeButton = page + .locator('[data-testid="search-close"]') + .or(page.locator('[aria-label="Close search"]')) + await closeButton.first().click() + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/overlay-closed-x.png` }) + }) + + test("search overlay closes via Escape key", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const searchToggle = page + .locator('[data-testid="search-toggle"]') + .or(page.locator("header button").first()) + await searchToggle.first().click() + await page.waitForTimeout(300) + await page.keyboard.press("Escape") + await page.waitForTimeout(300) + await page.screenshot({ + path: `${screenshotDir}/overlay-closed-escape.png`, + }) + }) +}) diff --git a/apps/web/e2e/flows/quiz-modal.spec.ts b/apps/web/e2e/flows/quiz-modal.spec.ts new file mode 100644 index 000000000..c1a8f8de4 --- /dev/null +++ b/apps/web/e2e/flows/quiz-modal.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/quiz-modal" + +test.describe("Quiz Modal", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.5), + ) + await page.waitForTimeout(500) + }) + + test("button renders with gradient mesh background", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(quizBtn).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/button-gradient.png` }) + }) + + test("button click opens modal dialog", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/modal-open.png` }) + }) + + test("modal with iframe and loading spinner", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(200) + await page.screenshot({ path: `${screenshotDir}/loading-spinner.png` }) + await page.waitForTimeout(2000) + await page.screenshot({ path: `${screenshotDir}/iframe-loaded.png` }) + } + }) + + test("loading spinner visible during iframe load", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(100) + } + await page.screenshot({ path: `${screenshotDir}/spinner-during-load.png` }) + }) + + test("close button click closes modal", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(500) + const closeBtn = page + .locator( + '[data-testid="modal-close"], dialog button, [aria-label="Close"]', + ) + .first() + if (await closeBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await closeBtn.click() + await page.waitForTimeout(300) + } + } + await page.screenshot({ path: `${screenshotDir}/modal-closed.png` }) + }) + + test("backdrop click closes modal", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(500) + await page.mouse.click(10, 10) + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/backdrop-close.png` }) + }) + + test("iframe sandbox attributes verification", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(500) + const iframe = page.locator("iframe").first() + if (await iframe.isVisible({ timeout: 2000 }).catch(() => false)) { + const sandbox = await iframe.getAttribute("sandbox") + if (sandbox) { + expect(sandbox).toBeTruthy() + } + } + } + await page.screenshot({ path: `${screenshotDir}/iframe-sandbox.png` }) + }) + + test("iframe title accessibility", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await quizBtn.click() + await page.waitForTimeout(500) + const iframe = page.locator("iframe").first() + if (await iframe.isVisible({ timeout: 2000 }).catch(() => false)) { + const title = await iframe.getAttribute("title") + expect(title).toBeTruthy() + } + } + await page.screenshot({ path: `${screenshotDir}/iframe-title.png` }) + }) + + test("button text display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/button-text.png` }) + }) + + test("animated mesh gradient on button", async ({ page }) => { + const quizBtn = page + .locator( + '[data-testid="quiz-button"], button:has-text("Quiz"), button:has-text("QUIZ"), [class*="quiz"] button', + ) + .first() + if (await quizBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.screenshot({ path: `${screenshotDir}/mesh-gradient-1.png` }) + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/mesh-gradient-2.png` }) + } + }) +}) diff --git a/apps/web/e2e/flows/related-questions.spec.ts b/apps/web/e2e/flows/related-questions.spec.ts new file mode 100644 index 000000000..5984f1499 --- /dev/null +++ b/apps/web/e2e/flows/related-questions.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/related-questions" + +test.describe("Related Questions Accordion", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.7), + ) + await page.waitForTimeout(500) + }) + + test("question expand — arrow rotates 180deg", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/expand.png` }) + }) + + test("question collapse", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.click() + await page.waitForTimeout(300) + await question.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/collapse.png` }) + }) + + test("only one open at a time (controlled)", async ({ page }) => { + const questions = page.locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + if ( + await questions + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await questions.first().click() + await page.waitForTimeout(300) + if ( + await questions + .nth(1) + .isVisible({ timeout: 1000 }) + .catch(() => false) + ) { + await questions.nth(1).click() + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/single-open.png` }) + }) + + test("keyboard navigation (Enter toggle)", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.focus() + await page.keyboard.press("Enter") + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/keyboard-enter.png` }) + }) + + test("hover state — bg-white/5 underline", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.hover() + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/hover.png` }) + }) + + test("markdown content in answers — lists rendered", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/markdown-content.png` }) + }) + + test("question icon display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/question-icon.png` }) + }) + + test("CTA button display and click (new tab)", async ({ page }) => { + const cta = page + .locator( + '[data-testid="accordion-cta"], [class*="accordion"] a[target="_blank"]', + ) + .first() + if (await cta.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(cta).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/cta-button.png` }) + }) + + test("heading display", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/heading.png` }) + }) + + test("accordion height animation", async ({ page }) => { + const question = page + .locator( + '[data-testid="accordion-trigger"], [class*="accordion"] button, details summary', + ) + .first() + if (await question.isVisible({ timeout: 3000 }).catch(() => false)) { + await question.click() + await page.waitForTimeout(100) + await page.screenshot({ path: `${screenshotDir}/height-animating.png` }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${screenshotDir}/height-complete.png` }) + } + }) +}) diff --git a/apps/web/e2e/flows/responsive.spec.ts b/apps/web/e2e/flows/responsive.spec.ts new file mode 100644 index 000000000..a8d18fa23 --- /dev/null +++ b/apps/web/e2e/flows/responsive.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/responsive" + +test.describe("Responsive Behavior", () => { + test("mobile viewport 320px (single column)", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/mobile-320.png` }) + }) + + test("tablet viewport 768px (2-column)", async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/tablet-768.png` }) + }) + + test("desktop viewport 1024px+ (multi-column)", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/desktop-1280.png` }) + }) + + test("carousel mobile (no nav arrows) vs desktop (arrows visible)", async ({ + page, + }) => { + await page.setViewportSize({ width: 375, height: 667 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => window.scrollTo(0, 300)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/carousel-mobile.png` }) + await page.setViewportSize({ width: 1280, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/carousel-desktop.png` }) + }) + + test("accordion mobile collapsed vs desktop expanded", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.6), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/accordion-mobile.png` }) + await page.setViewportSize({ width: 1280, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/accordion-desktop.png` }) + }) + + test("touch interactions on carousel (simulated)", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => window.scrollTo(0, 300)) + await page.waitForTimeout(500) + const carousel = page.locator('[class*="carousel"], .embla').first() + if (await carousel.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await carousel.boundingBox() + if (box) { + await page.touchscreen.tap( + box.x + box.width * 0.5, + box.y + box.height / 2, + ) + } + } + await page.screenshot({ path: `${screenshotDir}/touch-carousel.png` }) + }) + + test("viewport resize reflow", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.setViewportSize({ width: 1280, height: 720 }) + await page.screenshot({ path: `${screenshotDir}/reflow-desktop.png` }) + await page.setViewportSize({ width: 375, height: 667 }) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/reflow-mobile.png` }) + await page.setViewportSize({ width: 768, height: 1024 }) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/reflow-tablet.png` }) + }) + + test("image srcset responsive", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const img = page.locator("img[srcset]").first() + if (await img.isVisible({ timeout: 3000 }).catch(() => false)) { + const srcset = await img.getAttribute("srcset") + expect(srcset).toBeTruthy() + } + await page.screenshot({ path: `${screenshotDir}/image-srcset.png` }) + }) + + test("video player responsive sizing", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/player-desktop.png` }) + await page.setViewportSize({ width: 375, height: 667 }) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/player-mobile.png` }) + }) +}) diff --git a/apps/web/e2e/flows/routes-page-loading.spec.ts b/apps/web/e2e/flows/routes-page-loading.spec.ts new file mode 100644 index 000000000..6516dfedc --- /dev/null +++ b/apps/web/e2e/flows/routes-page-loading.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/routes-page-loading" + +test.describe("Routes & Page Loading", () => { + test("home page / loads with sections", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const content = page.locator("main, [role='main'], body > div").first() + await expect(content).toBeVisible() + await page.screenshot({ path: `${screenshotDir}/home.png` }) + }) + + test("/watch/[slug] dynamic route (via link)", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const watchLink = page + .locator("a[href*='/']") + .filter({ hasNotText: /search|demo/ }) + .first() + if (await watchLink.isVisible({ timeout: 3000 }).catch(() => false)) { + await watchLink.click() + await page.waitForLoadState("networkidle") + } + await page.screenshot({ path: `${screenshotDir}/watch-slug.png` }) + }) + + test("/watch/[slug]/[locale] localized route", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/localized.png` }) + }) + + test("empty experience shows ExperienceEmpty", async ({ page }) => { + await page.goto("/nonexistent-slug-xyz-12345") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/empty-experience.png` }) + }) + + test("missing experience (404) shows ExperienceEmpty", async ({ page }) => { + await page.goto("/this-does-not-exist-at-all-404") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/404.png` }) + }) + + test("experience error shows ExperienceError with message", async ({ + page, + }) => { + await page.route("**/graphql*", (route) => route.abort("failed")) + await page.goto("/some-slug") + await page.waitForTimeout(3000) + await page.screenshot({ path: `${screenshotDir}/experience-error.png` }) + }) + + test("page metadata — title, description, OG tags", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const title = await page.title() + expect(title.length).toBeGreaterThan(0) + await page.locator('meta[name="description"]').getAttribute("content") + await page.screenshot({ path: `${screenshotDir}/metadata.png` }) + }) + + test("demo recommendations page load", async ({ page }) => { + await page.goto("/demo-recommendations") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/demo-recs.png` }) + }) + + test("demo recommendations locale toggle", async ({ page }) => { + await page.goto("/demo-recommendations") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/demo-recs-locale.png` }) + }) + + test("demo recommendations video not found", async ({ page }) => { + await page.goto("/demo-recommendations/nonexistent/en") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/demo-recs-not-found.png` }) + }) + + test("demo recommendations locale filter (en, es, fr)", async ({ page }) => { + await page.goto("/demo-recommendations") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/demo-recs-filter.png` }) + }) + + test("loading states (Suspense boundaries)", async ({ page }) => { + await page.goto("/") + await page.screenshot({ path: `${screenshotDir}/loading-state.png` }) + }) + + test("ISR revalidation behavior", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/isr-first.png` }) + await page.reload() + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/isr-second.png` }) + }) + + test("locale slug detection (isLocale)", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/locale-detection.png` }) + }) +}) diff --git a/apps/web/e2e/flows/search-overlay.spec.ts b/apps/web/e2e/flows/search-overlay.spec.ts new file mode 100644 index 000000000..b7970396a --- /dev/null +++ b/apps/web/e2e/flows/search-overlay.spec.ts @@ -0,0 +1,219 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/search-overlay" + +test.describe("Search Overlay", () => { + async function openSearchOverlay(page: import("@playwright/test").Page) { + await page.goto("/") + await page.waitForLoadState("networkidle") + const searchToggle = page + .locator('[data-testid="search-toggle"]') + .or(page.locator("header button").first()) + await searchToggle.first().click() + await page.waitForTimeout(400) + } + + test("empty overlay initial state — input focused, no results", async ({ + page, + }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await expect(input).toBeFocused() + await page.screenshot({ path: `${screenshotDir}/empty-initial.png` }) + }) + + test("type query with debounce — results load", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/query-results.png` }) + }) + + test("loading skeleton display after delay", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("test query") + await page.waitForTimeout(600) + await page.screenshot({ path: `${screenshotDir}/loading-skeleton.png` }) + }) + + test("rapid query changes — only latest result shown", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("first") + await page.waitForTimeout(100) + await input.fill("second") + await page.waitForTimeout(100) + await input.fill("Jesus") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/rapid-query-latest.png` }) + }) + + test("search results animate in with staggered animation", async ({ + page, + }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1500) + await page.screenshot({ path: `${screenshotDir}/staggered-animation.png` }) + }) + + test("no results state", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("xyznonexistentquery12345") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/no-results.png` }) + }) + + test("search error state with Retry button", async ({ page }) => { + await page.route("**/graphql*", (route) => route.abort("failed")) + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("test") + await page.waitForTimeout(1500) + await page.screenshot({ path: `${screenshotDir}/error-retry.png` }) + }) + + test("load more results (pagination)", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1500) + const loadMore = page.locator( + 'button:has-text("Load more"), button:has-text("Show more"), button:has-text("More")', + ) + if (await loadMore.isVisible({ timeout: 2000 }).catch(() => false)) { + await loadMore.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/load-more.png` }) + }) + + test("load more error + retry", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1500) + await page.route("**/graphql*", (route) => route.abort("failed")) + const loadMore = page.locator( + 'button:has-text("Load more"), button:has-text("Show more"), button:has-text("More")', + ) + if (await loadMore.isVisible({ timeout: 2000 }).catch(() => false)) { + await loadMore.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/load-more-error.png` }) + }) + + test("click result card navigates to watch page", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1500) + const resultCard = page + .locator('[data-testid="search-result"]') + .or(page.locator('a[href*="/"]').filter({ hasText: /.+/ })) + if ( + await resultCard + .first() + .isVisible({ timeout: 2000 }) + .catch(() => false) + ) { + await resultCard.first().click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/result-navigate.png` }) + }) + + test("tab focus trap (forward and backward wrap)", async ({ page }) => { + await openSearchOverlay(page) + for (let i = 0; i < 10; i++) { + await page.keyboard.press("Tab") + } + await page.screenshot({ path: `${screenshotDir}/focus-trap-forward.png` }) + for (let i = 0; i < 5; i++) { + await page.keyboard.press("Shift+Tab") + } + await page.screenshot({ path: `${screenshotDir}/focus-trap-backward.png` }) + }) + + test("body scroll lock while overlay open", async ({ page }) => { + await openSearchOverlay(page) + const scrollY = await page.evaluate(() => { + window.scrollTo(0, 100) + return window.scrollY + }) + expect(scrollY).toBeLessThanOrEqual(1) + await page.screenshot({ path: `${screenshotDir}/scroll-lock.png` }) + }) + + test("long query truncation (200 char limit)", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + const longQuery = "a".repeat(250) + await input.fill(longQuery) + await page.waitForTimeout(500) + const value = await input.inputValue() + expect(value.length).toBeLessThanOrEqual(200) + await page.screenshot({ path: `${screenshotDir}/long-query.png` }) + }) + + test("special characters in search query", async ({ page }) => { + await openSearchOverlay(page) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill('') + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/special-chars.png` }) + }) +}) diff --git a/apps/web/e2e/flows/search-page.spec.ts b/apps/web/e2e/flows/search-page.spec.ts new file mode 100644 index 000000000..a158b651b --- /dev/null +++ b/apps/web/e2e/flows/search-page.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/search-page" + +test.describe("Search Page /search", () => { + test("load with query parameter shows results", async ({ page }) => { + await page.goto("/search?q=Jesus") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/query-results.png` }) + }) + + test("load without query shows empty state", async ({ page }) => { + await page.goto("/search") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/empty-state.png` }) + }) + + test("search input debounce updates URL via router.replace", async ({ + page, + }) => { + await page.goto("/search") + await page.waitForLoadState("networkidle") + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.fill("Jesus") + await page.waitForTimeout(1000) + await expect(page).toHaveURL(/q=Jesus/) + await page.screenshot({ path: `${screenshotDir}/url-updated.png` }) + }) + + test("clear search input clears results", async ({ page }) => { + await page.goto("/search?q=Jesus") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(500) + const input = page + .locator( + 'input[type="search"], input[type="text"], input[placeholder*="earch"]', + ) + .first() + await input.clear() + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/cleared.png` }) + }) + + test("infinite scroll or load more button", async ({ page }) => { + await page.goto("/search?q=Jesus") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(1000) + const loadMore = page.locator( + 'button:has-text("Load more"), button:has-text("Show more")', + ) + if (await loadMore.isVisible({ timeout: 2000 }).catch(() => false)) { + await loadMore.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/load-more.png` }) + }) + + test("empty results state for nonexistent query", async ({ page }) => { + await page.goto("/search?q=xyznonexistentquery12345") + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${screenshotDir}/no-results.png` }) + }) + + test("loading skeleton on page", async ({ page }) => { + await page.goto("/search?q=Jesus") + await page.screenshot({ path: `${screenshotDir}/loading-skeleton.png` }) + }) + + test("error display with retry", async ({ page }) => { + await page.route("**/graphql*", (route) => route.abort("failed")) + await page.goto("/search?q=Jesus") + await page.waitForTimeout(2000) + await page.screenshot({ path: `${screenshotDir}/error-retry.png` }) + }) + + test("page metadata title includes query", async ({ page }) => { + await page.goto("/search?q=Jesus") + await page.waitForLoadState("networkidle") + const title = await page.title() + expect(title.toLowerCase()).toContain("search") + await page.screenshot({ path: `${screenshotDir}/metadata-title.png` }) + }) +}) diff --git a/apps/web/e2e/flows/section-rendering.spec.ts b/apps/web/e2e/flows/section-rendering.spec.ts new file mode 100644 index 000000000..f3bc43d58 --- /dev/null +++ b/apps/web/e2e/flows/section-rendering.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/section-rendering" + +test.describe("Section Rendering", () => { + test("home page renders all section types", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/home-all-sections.png` }) + }) + + test("VideoHero section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const hero = page + .locator("video, [data-testid='hero'], [class*='hero']") + .first() + if (await hero.isVisible({ timeout: 5000 }).catch(() => false)) { + await expect(hero).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/video-hero.png` }) + }) + + test("NavigationCarousel section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/nav-carousel.png` }) + }) + + test("VideoCarousel section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => window.scrollTo(0, 300)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/video-carousel.png` }) + }) + + test("MediaCollection section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => window.scrollTo(0, 600)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/media-collection.png` }) + }) + + test("BibleQuotes section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight / 2), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/bible-quotes.png` }) + }) + + test("RelatedQuestions section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.7), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/related-questions.png` }) + }) + + test("QuizButton section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.5), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/quiz-button.png` }) + }) + + test("AdventCountdown section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.6), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/advent-countdown.png` }) + }) + + test("EasterDates section renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.6), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/easter-dates.png` }) + }) + + test("TextSection renders", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight * 0.8), + ) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/text-section.png` }) + }) + + test("multiple sections render in sequence on home", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const sections = page.locator("section, [data-section-key]") + const count = await sections.count() + expect(count).toBeGreaterThan(0) + await page.screenshot({ path: `${screenshotDir}/sections-count.png` }) + }) + + test("sections render on experience page", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const link = page + .locator("a[href*='/']") + .filter({ hasNotText: "search" }) + .first() + if (await link.isVisible({ timeout: 3000 }).catch(() => false)) { + const href = await link.getAttribute("href") + if (href && href !== "/") { + await page.goto(href) + await page.waitForLoadState("networkidle") + } + } + await page.screenshot({ path: `${screenshotDir}/experience-sections.png` }) + }) + + test("unknown section type filtered (Error blocks)", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const errorBlock = page.locator( + '[data-testid="error-block"], [class*="error-block"]', + ) + expect(await errorBlock.count()).toBe(0) + await page.screenshot({ path: `${screenshotDir}/no-error-blocks.png` }) + }) + + test("section with background color renders correctly", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.evaluate(() => window.scrollTo(0, 400)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/bg-color-section.png` }) + }) + + test("section with heading renders correctly", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const headings = page.locator("h2, h3") + const count = await headings.count() + expect(count).toBeGreaterThan(0) + await page.screenshot({ path: `${screenshotDir}/section-headings.png` }) + }) + + test("full page scroll captures all sections", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.screenshot({ path: `${screenshotDir}/full-page-top.png` }) + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight / 3), + ) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/full-page-mid.png` }) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/full-page-bottom.png` }) + }) + + test("section dispatcher handles missing data gracefully", async ({ + page, + }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + const errors = await page.evaluate(() => { + const consoleErrors: string[] = [] + return consoleErrors + }) + expect(errors.length).toBe(0) + await page.screenshot({ path: `${screenshotDir}/no-errors.png` }) + }) +}) diff --git a/apps/web/e2e/flows/video-hero.spec.ts b/apps/web/e2e/flows/video-hero.spec.ts new file mode 100644 index 000000000..f67cc06a0 --- /dev/null +++ b/apps/web/e2e/flows/video-hero.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/video-hero" + +test.describe("Video Hero", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("auto-play on page load (muted)", async ({ page }) => { + const video = page.locator("video").first() + if (await video.isVisible({ timeout: 5000 }).catch(() => false)) { + const muted = await video.evaluate((el: HTMLVideoElement) => el.muted) + expect(muted).toBe(true) + } + await page.screenshot({ path: `${screenshotDir}/autoplay-muted.png` }) + }) + + test("pause on scroll down (>100px threshold)", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 200)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/paused-scroll.png` }) + }) + + test("resume on scroll up (<50px)", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 200)) + await page.waitForTimeout(300) + await page.evaluate(() => window.scrollTo(0, 0)) + await page.waitForTimeout(500) + await page.screenshot({ path: `${screenshotDir}/resume-scroll.png` }) + }) + + test("mute button toggle", async ({ page }) => { + const muteBtn = page + .locator( + '[data-testid="hero-mute"], [class*="hero"] button[aria-label*="ute"], [class*="hero"] button:has(svg)', + ) + .first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/mute-toggle.png` }) + }) + + test("unmute resets to start and plays", async ({ page }) => { + const muteBtn = page + .locator( + '[data-testid="hero-mute"], [class*="hero"] button[aria-label*="ute"], [class*="hero"] button:has(svg)', + ) + .first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/unmute-reset.png` }) + }) + + test("unmute-once flag — only reset first time", async ({ page }) => { + const muteBtn = page + .locator( + '[data-testid="hero-mute"], [class*="hero"] button[aria-label*="ute"], [class*="hero"] button:has(svg)', + ) + .first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(300) + await muteBtn.click() + await page.waitForTimeout(300) + await muteBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/unmute-once.png` }) + }) + + test("heading and subheading display", async ({ page }) => { + const heading = page + .locator( + '[class*="hero"] h1, [class*="hero"] h2, [data-testid="hero-heading"]', + ) + .first() + if (await heading.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(heading).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/heading-subheading.png` }) + }) + + test("CTA button display and click", async ({ page }) => { + const cta = page + .locator( + '[class*="hero"] a, [class*="hero"] button, [data-testid="hero-cta"]', + ) + .first() + if (await cta.isVisible({ timeout: 3000 }).catch(() => false)) { + await cta.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/cta-click.png` }) + }) + + test("RouteVideo vs static URL source selection", async ({ page }) => { + const video = page.locator("video").first() + if (await video.isVisible({ timeout: 3000 }).catch(() => false)) { + const src = await video.evaluate( + (el: HTMLVideoElement) => el.src || el.querySelector("source")?.src, + ) + if (src) { + expect(src).toMatch(/https?:\/\//) + } + } + await page.screenshot({ path: `${screenshotDir}/video-source.png` }) + }) + + test("volume change event handling", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/volume-change.png` }) + }) + + test("linear gradient overlay", async ({ page }) => { + await page.screenshot({ path: `${screenshotDir}/gradient-overlay.png` }) + }) + + test("scroll-driven blur/dim effect", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 80)) + await page.waitForTimeout(300) + await page.screenshot({ path: `${screenshotDir}/blur-dim.png` }) + }) + + test("hero container dimensions", async ({ page }) => { + const hero = page.locator('[class*="hero"], [data-testid="hero"]').first() + if (await hero.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await hero.boundingBox() + if (box) { + expect(box.width).toBeGreaterThan(0) + expect(box.height).toBeGreaterThan(0) + } + } + await page.screenshot({ path: `${screenshotDir}/hero-dimensions.png` }) + }) +}) diff --git a/apps/web/e2e/flows/video-player.spec.ts b/apps/web/e2e/flows/video-player.spec.ts new file mode 100644 index 000000000..fdcd47298 --- /dev/null +++ b/apps/web/e2e/flows/video-player.spec.ts @@ -0,0 +1,199 @@ +import { test, expect } from "@playwright/test" + +const screenshotDir = "../screenshots/browser/video-player" + +test.describe("Video Player", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + }) + + test("play video via play button", async ({ page }) => { + const player = page.locator("video, [data-testid='video-player']").first() + if (await player.isVisible({ timeout: 3000 }).catch(() => false)) { + await player.click() + await page.waitForTimeout(1000) + } + await page.screenshot({ path: `${screenshotDir}/play.png` }) + }) + + test("pause video", async ({ page }) => { + const player = page.locator("video, [data-testid='video-player']").first() + if (await player.isVisible({ timeout: 3000 }).catch(() => false)) { + await player.click() + await page.waitForTimeout(500) + await player.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/pause.png` }) + }) + + test("seek via progress bar click at 50%", async ({ page }) => { + const progressBar = page + .locator( + ".vjs-progress-control, [data-testid='progress-bar'], input[type='range']", + ) + .first() + if (await progressBar.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await progressBar.boundingBox() + if (box) { + await page.mouse.click(box.x + box.width * 0.5, box.y + box.height / 2) + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/seek-50.png` }) + }) + + test("seek via slider drag", async ({ page }) => { + const slider = page + .locator( + ".vjs-progress-control, [data-testid='progress-bar'], input[type='range']", + ) + .first() + if (await slider.isVisible({ timeout: 3000 }).catch(() => false)) { + const box = await slider.boundingBox() + if (box) { + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.7, box.y + box.height / 2, { + steps: 10, + }) + await page.mouse.up() + await page.waitForTimeout(500) + } + } + await page.screenshot({ path: `${screenshotDir}/seek-drag.png` }) + }) + + test("time display accuracy", async ({ page }) => { + const timeDisplay = page + .locator(".vjs-time-control, [data-testid='time-display']") + .first() + if (await timeDisplay.isVisible({ timeout: 3000 }).catch(() => false)) { + const text = await timeDisplay.textContent() + expect(text).toMatch(/\d+:\d+/) + } + await page.screenshot({ path: `${screenshotDir}/time-display.png` }) + }) + + test("mute toggle shows large center icon", async ({ page }) => { + const muteBtn = page + .locator( + ".vjs-mute-control, [data-testid='mute-button'], [aria-label*='ute']", + ) + .first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/muted.png` }) + }) + + test("unmute toggle removes icon", async ({ page }) => { + const muteBtn = page + .locator( + ".vjs-mute-control, [data-testid='mute-button'], [aria-label*='ute']", + ) + .first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(300) + await muteBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/unmuted.png` }) + }) + + test("mute state persists across pause/play", async ({ page }) => { + const muteBtn = page + .locator( + ".vjs-mute-control, [data-testid='mute-button'], [aria-label*='ute']", + ) + .first() + const player = page.locator("video, [data-testid='video-player']").first() + if (await muteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await muteBtn.click() + await page.waitForTimeout(300) + if (await player.isVisible()) { + await player.click() + await page.waitForTimeout(300) + await player.click() + await page.waitForTimeout(300) + } + } + await page.screenshot({ path: `${screenshotDir}/mute-persist.png` }) + }) + + test("fullscreen enter", async ({ page }) => { + const fsBtn = page + .locator( + ".vjs-fullscreen-control, [data-testid='fullscreen-button'], [aria-label*='ullscreen']", + ) + .first() + if (await fsBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await fsBtn.click() + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/fullscreen-enter.png` }) + }) + + test("fullscreen exit", async ({ page }) => { + const fsBtn = page + .locator( + ".vjs-fullscreen-control, [data-testid='fullscreen-button'], [aria-label*='ullscreen']", + ) + .first() + if (await fsBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await fsBtn.click() + await page.waitForTimeout(500) + await page.keyboard.press("Escape") + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/fullscreen-exit.png` }) + }) + + test("poster/thumbnail display before play", async ({ page }) => { + await page.goto("/") + const poster = page + .locator(".vjs-poster, [data-testid='video-poster'], video[poster]") + .first() + if (await poster.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(poster).toBeVisible() + } + await page.screenshot({ path: `${screenshotDir}/poster.png` }) + }) + + test("autoplay on viewport scroll for Video section", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 500)) + await page.waitForTimeout(1500) + await page.screenshot({ path: `${screenshotDir}/autoplay-scroll.png` }) + }) + + test("progress slider keyboard interaction with arrow keys", async ({ + page, + }) => { + const slider = page + .locator(".vjs-progress-control, input[type='range']") + .first() + if (await slider.isVisible({ timeout: 3000 }).catch(() => false)) { + await slider.focus() + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(300) + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(300) + } + await page.screenshot({ path: `${screenshotDir}/keyboard-seek.png` }) + }) + + test("spacebar play/pause toggle", async ({ page }) => { + const player = page.locator("video, [data-testid='video-player']").first() + if (await player.isVisible({ timeout: 3000 }).catch(() => false)) { + await player.focus() + await page.keyboard.press("Space") + await page.waitForTimeout(500) + await page.keyboard.press("Space") + await page.waitForTimeout(500) + } + await page.screenshot({ path: `${screenshotDir}/spacebar-toggle.png` }) + }) +}) diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts new file mode 100644 index 000000000..402d24228 --- /dev/null +++ b/apps/web/e2e/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test" + +const baseURL = process.env.BASE_URL ?? "http://localhost:3000" + +export default defineConfig({ + testDir: "./flows", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: process.env.PW_SKIP_WEBSERVER + ? undefined + : { + command: "pnpm run dev", + url: baseURL, + reuseExistingServer: true, + timeout: 30_000, + }, +}) diff --git a/apps/web/e2e/screenshots/.gitkeep b/apps/web/e2e/screenshots/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/package.json b/apps/web/package.json index 0a911296b..f3c23f703 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test --config e2e/playwright.config.ts" }, "dependencies": { "@apollo/client": "^4.1.4", @@ -36,10 +37,13 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4.1.18", + "@testing-library/react": "^16.3.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/video.js": "^7.3.56", + "jsdom": "^26.1.0", "eslint": "^9.0.0", "eslint-config-next": "^16.1.6", "postcss": "^8.5.6", diff --git a/apps/web/src/components/SearchOverlay.tsx b/apps/web/src/components/SearchOverlay.tsx index bc38bf561..c243a0246 100644 --- a/apps/web/src/components/SearchOverlay.tsx +++ b/apps/web/src/components/SearchOverlay.tsx @@ -243,6 +243,7 @@ export function SearchOverlay({ open, onClose, closing }: SearchOverlayProps) { onClick={onClose} className="rounded-full p-3 text-stone-400 transition hover:text-white" aria-label="Close search" + data-testid="search-close" > - +

{displayTitle} @@ -117,7 +118,10 @@ export function AdventCountdown({ data }: AdventCountdownProps) { ) : (
-

+

{days}

diff --git a/apps/web/src/components/sections/BibleQuotesCarousel.tsx b/apps/web/src/components/sections/BibleQuotesCarousel.tsx index 2d76ef8c4..b06079039 100644 --- a/apps/web/src/components/sections/BibleQuotesCarousel.tsx +++ b/apps/web/src/components/sections/BibleQuotesCarousel.tsx @@ -35,7 +35,7 @@ export function BibleQuotesCarousel({ data }: BibleQuotesCarouselProps) { if (validQuotes.length === 0) return null return ( -

+
- - {quote.reference} - -

- {quote.text} -

- +
+ + + {quote.reference} + +

+ {quote.text} +

+
+
) } @@ -169,6 +171,7 @@ function FreeResourceCard({ quote }: { quote: QuoteItem }) {