diff --git a/.gitattributes b/.gitattributes index 9d589950..8fd332f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,10 @@ * text=auto eol=lf +# Shell scripts executed under WSL MUST stay LF: a CRLF `set -euo pipefail\r` +# makes WSL bash abort with "set: pipefail: invalid option name", which silently +# broke the verthor vertical-reframe path in v1.4 (shorts never generated). +# `* text=auto eol=lf` already implies this; this explicit rule documents the +# hard requirement and survives edits to the wildcard line. +*.sh text eol=lf *.png binary *.jpg binary *.jpeg binary diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index df03237a..03f49650 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,6 +31,11 @@ name: e2e on: workflow_dispatch: + inputs: + update_visual_baselines: + description: "Regenerate the Windows (*-win32.png) visual baselines instead of diffing (uploads them as the 'updated-visual-baselines' artifact; does NOT commit)" + type: boolean + default: false schedule: # Nightly at 04:17 UTC (off the hour to avoid GitHub's scheduler congestion). - cron: "17 4 * * *" @@ -62,14 +67,23 @@ jobs: - name: Install sidecar + E2E runtime deps (CPU) run: | python -m pip install --upgrade pip - pip install pytest pytest-cov + # hypothesis is imported by sidecar/tests/conftest.py at collection time + # (it registers the deterministic "ci" profile), so EVERY pytest run — + # including `pytest -m e2e` — needs it. `sidecar[dev]` declares it, but the + # `--no-deps` install below skips the extra's transitives, so install the + # test tools explicitly here. + pip install pytest pytest-cov hypothesis # Package itself (pure-logic modules) + dev extras, no heavy transitives. pip install -e "sidecar[dev]" --no-deps # The CPU runtime deps the opt-in E2E flows actually exercise. Unlike the # standard gate, the E2E suite IS allowed the heavier (still CPU-only) # stack: real tiny faster-whisper, scenedetect, av, onnxruntime, cv2. + # opencv is PINNED to the version the reframe engine is validated against + # (pyproject reframe-gpu extra, A6 lesson 5). Unpinned resolves to OpenCV + # 5.x, whose cv2.FaceDetectorYN/YuNet path is a major-version break and + # fails the claudeshorts reframe. pip install \ - httpx numpy opencv-python-headless \ + httpx numpy opencv-python-headless==4.13.0.92 \ faster-whisper scenedetect av onnxruntime \ huggingface_hub tokenizers @@ -146,6 +160,27 @@ jobs: python -m pip install --upgrade pip pip install -e "sidecar[dev]" --no-deps + - name: Install the reframe + asset-provisioning runtime deps for the real Make-Shorts pipeline + # golden-journey.spec.ts drives the REAL reframe→export pipeline against the + # dev-build sidecar (RF_PY = this system python on EVERY leg, incl. Windows — + # only packaged.spec drives the shipped .exe). Two runtime needs the + # `sidecar[dev] --no-deps` install above deliberately omits: + # * cv2 — the final vertical-reframe stage (reframe_claudeshorts) HARD-requires + # it and fails LOUD when missing (no silent center-crop — the WU-3 contract). + # PINNED to the reframe-validated version (pyproject reframe-gpu extra, A6 + # lesson 5); unpinned resolves to OpenCV 5.x, a FaceDetectorYN/YuNet + # major-version break. opencv-python-headless bundles FaceDetectorYN + the + # ONNX runtime YuNet uses (no GUI libs); numpy rides along. + # * httpx — the asset downloader (manager._default_http_client) lazily imports + # it. golden-journey provisions the YuNet model via a live sidecar + # (fixtures.provisionAssets → assets.ensure); WITHOUT httpx that download + # throws, assets.ensure GRACEFULLY SKIPS the item but still emits job.done, + # so YuNet never lands and the reframe reports "not provisioned". huggingface_hub + # covers the HF-snapshot install path for completeness. + # In production the packaged app installs these on first-run bootstrap. Mirrors + # the e2e-sidecar job's install. + run: pip install "opencv-python-headless==4.13.0.92" numpy httpx huggingface_hub + - name: Install app deps working-directory: app run: npm ci @@ -176,7 +211,13 @@ jobs: - name: Package the Windows app (electron-builder → dist/) if: runner.os == 'Windows' working-directory: app - run: npx electron-builder --config ../electron-builder.yml --win + # `--publish never`: this is an E2E BUILD, not a release. Without it, + # electron-builder detects CI and implicitly tries to publish, which + # fails with "GitHub Personal Access Token is not set" (no GH_TOKEN) — + # AFTER it has already built + signed dist/. We only need dist/ to exist + # for packaged.spec.ts to drive the shipped .exe; publishing is the + # release workflow's job, not this gate's. + run: npx electron-builder --config ../electron-builder.yml --win --publish never - name: E2E GUI — vitest DOM caption + nasty-caption proofs working-directory: app @@ -225,13 +266,41 @@ jobs: # Windows leg to reuse the already-built dev app. Runs against the dev build # (RF_E2E_DEV=1) — the same target the baselines were captured against. - name: E2E GUI — VISUAL screenshot-diff + A11Y (axe) [Windows only] - if: runner.os == 'Windows' + # Normal (blocking) path: diff against the committed `*-win32.png` baselines. + # Unchanged for the nightly schedule and any dispatch that leaves the + # `update_visual_baselines` box unchecked (input is '' on schedule, 'false' + # when unchecked — either way not 'true', so this diff run fires as before). + if: runner.os == 'Windows' && github.event.inputs.update_visual_baselines != 'true' working-directory: app env: RF_PY: ${{ steps.setup-python.outputs.python-path }} RF_E2E_DEV: "1" run: npm run test:e2e:visual + # On-demand baseline REGEN: only when the workflow_dispatch box is checked. + # `--update-snapshots` REWRITES the `*-win32.png` baselines in-place (Playwright + # exits 0 after writing), so this run is non-blocking by design; the regenerated + # PNGs are uploaded below for the maintainer to download + commit. Never runs on + # the schedule (input is '' there), so the nightly gate stays a real diff. + - name: E2E GUI — VISUAL baseline REGEN (--update-snapshots) [Windows only, on demand] + if: runner.os == 'Windows' && github.event.inputs.update_visual_baselines == 'true' + working-directory: app + env: + RF_PY: ${{ steps.setup-python.outputs.python-path }} + RF_E2E_DEV: "1" + run: npm run test:e2e:visual:update + + - name: Upload regenerated visual baselines + if: always() && runner.os == 'Windows' && github.event.inputs.update_visual_baselines == 'true' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: updated-visual-baselines + # Playwright stores baselines next to each spec (playwright.visual.config.ts + # testDir: ./e2e/visual) as `.spec.ts-snapshots/-win32.png`. + path: app/e2e/visual/**/*-win32.png + if-no-files-found: warn + retention-days: 7 + - name: Upload Playwright report if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 diff --git a/.gitignore b/.gitignore index 3d3718c3..fe375bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ app/.vite/ # never commit. Screenshot BASELINES under e2e/**/-snapshots/ ARE tracked. app/playwright-report/ app/test-results/ +# Ad-hoc end-to-end scratch outputs (probe scripts + runner datadirs/library.db +# written under e2e_artifacts/ during manual E2E drives) — regenerable, never commit. +e2e_artifacts/ # Python __pycache__/ @@ -87,6 +90,10 @@ build/ffmpeg/ # regenerable by the same prep script — never commit it (mirrors build/ffmpeg/). # NOTE: build/python-embed/ (py3.12) IS tracked historically; this only covers 3.14. build/python-embed-314/ +# Stray duplicate icon dir — the REAL app icons live in build/icons/ (plural, +# committed by WU A2). build/icon/ (singular) is a regenerable leftover dup an +# icon step emits; never commit it (the tracked build/icons/ is the source of truth). +build/icon/ # Runtime data root (models/exports/envs — relocatable via MEDIA_STUDIO_CONFIG_DIR) /data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 897bac9d..d4b14c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,192 @@ All notable changes to Reframe — Media Studio are documented here. This project adheres to [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [1.4.1] — 2026-07-08 + +**Reframe v1.4.1 — post-v1.4 hardening.** A focused patch over 1.4.0: it fixes a real +cloud-vision egress bug, hardens the renderer against a white-screen, corrects the +Workspace tab-strip accessibility, and re-pairs the GPU/reframe runtime pins — all +surfaced by driving the (never-before-CI'd) v1.4 line through the full `quality` + +`e2e` gauntlet. Ships **in place** over the GitHub-Releases auto-update feed. Both +coverage gates stay at **strict 100% line + branch** (renderer vitest **and** sidecar +pytest), and the golden-journey end-to-end acceptance test now passes on CI (Windows + +Linux), proving Make-Shorts produces a real vertical short. + +### Fixed + +- **Cloud vision in Make-Shorts jobs no longer crashes/degrades.** The per-request key + overlay closed before a job's worker thread resolved the cloud provider, so tier≥2 + vision in `thumbnail.select` / `phase8.select` read redacted key markers off-thread. + Keys are now captured synchronously in the handler and snapshotted onto the worker + thread (mirroring `index.build`). +- **No more white screen on a renderer/RPC hiccup.** An `ErrorBoundary` now wraps the + whole app and every eager-RPC mount site guards the synchronous preload-bridge throw, + so a transient failure renders an inline error instead of a blank window; the main + process gains render-process-gone / uncaught-exception recovery. +- **Workspace tab-strip accessibility (WCAG).** The grouped tab strip exposed its + `role="tab"` buttons under `region` wrappers with non-tab controls inside the + `role="tablist"` (critical `aria-required-parent` / `aria-required-children`), and a + cluster caption sat at 3.85:1 contrast. The tablist now owns only tabs, and the + caption meets AA. +- **Reframe/GPU runtime pins re-paired.** `torch`/`torchaudio` are re-pinned to the + paired `+cu128` build and `opencv` to the reframe-validated `4.13.0.92`, so the + first-run reframe stack installs a consistent, validated set (the claudeshorts + vertical-reframe engine now fails LOUD with an actionable message if its YuNet model + is not provisioned, instead of degrading silently). + +## [1.4.0] — 2026-07-07 + +**Reframe v1.4 — the experience overhaul.** One big release that turns the raw +first-run into a plug-and-play experience: it kills the three errors that greeted +new installs, makes setup visible and choosable, surfaces where every clip came +from, and re-skins the whole app in a calm cool blue-gray. Almost all of it is +wiring + making existing machinery visible, not new capability. Ships **in place** +over the existing GitHub-Releases auto-update feed (the bump to **1.4.0** is what +lets that feed report an update at all — 1.3.0 reports "no update" to itself). +Both coverage gates stay at **strict 100% line + branch** (renderer vitest **and** +sidecar pytest) under the single `quality` CI gate. + +### Fixed — the three first-launch errors are gone + +- **"Sidecar is not running" on first launch** — the app no longer drops you into + a dead Library while Python and the models are still installing. A full-screen + **"Setting up Reframe"** first-run screen now stands in for the tabbed shell + while provisioning, driven by the real `bootstrap.progress` stream (phase + + progress bar), and auto-transitions into the app the moment the sidecar reports + **running**. Setup failures / offline / partial states are first-class in that + view with a **Retry** (no dead-end, no raw crash banner). +- **Player preview "code 4" / clipped-at-the-top** — the preview is now + sidecar-state-aware: while the playback proxy is still building it shows + **"Building preview…"** (not a raw exit-code), reloads when `proxy.state` goes + ready, and reports a real error loudly with its actual reason. The + fixed-height/overflow CSS that cut the frame "in half at the top" is fixed. +- **Settings / first-run confusion** — the local-vs-cloud **FirstRunChooser** that + was buried in Settings is surfaced into the first-run flow, so the choice is made + up front instead of failing later. + +### Added — in-app install profiles (Min / Default / Full / Custom) + +- **Choose what gets installed, in the app, on first run.** A first-run profile + picker (**Minimum · Default · Full · Custom**) shows what each level includes, + why, and its approximate download size, then routes the choice into first-run + bootstrap (which previously spawned with no arguments and always fetched the same + set). The profile→asset map is a **single source of truth** with a conformance + test asserting it stays a superset of the CORE model floor (YuNet / S3FD / LR-ASD) + and matches the sidecar — every level keeps that floor so the app never silently + falls back to a plain center crop. + +### Added — Library provenance, relink & keep-a-copy + +- **See where every clip came from.** Each library item now shows its **source + path**, an **on-disk / MISSING** badge, and **open-in-folder**; a moved or + renamed source can be **relinked** with a content-hash verify so you re-point to + the right file, not just any file. Existing videos get their `content_hash` + back-filled lazily (pin-on-view) so relink works for the clips you already have, + not only new adds; a source that is already gone surfaces "relink unavailable" + honestly. +- **Keep a managed copy of originals (opt-in).** An opt-in keep-a-copy imports a + managed duplicate of source files with a **free-space preflight**, content-hash + dedup, a cumulative size cap + meter, and an **atomic** copy (temp + replace with + rollback) so it never half-writes or silently doubles your disk use. + +### Added — API keys: reveal & live usage + +- **Providers & Keys** lets you **reveal** a stored key on demand and shows + **per-key live usage**, so you can confirm the right key is set and watch spend — + while the key stays redacted over the RPC/IPC bridge by default and is never + logged. + +### Changed — cool blue-gray visual overhaul + +- **A calm, cool blue-gray re-skin, token-first.** Design tokens v2 lift the base + off near-black toward a faint cool blue-gray and **widen the surface ladder** so + cards, panels, and wells stratify with real elevation and a top-lit atmosphere; + every component inherits it. A humanized header replaces the QUALITY/ROUTING + jargon with plain-language iconed toggles ("Runs on: This computer / Cloud", + "Where jobs run: …") plus an egress dot and a Jobs status pill (same values and + handlers — relabel only, **local stays default, cloud strictly opt-in**). A + designed state system lands skeleton-shimmer loading, ghost-poster empties, a + calm-amber **"Reconnecting…"** (hard-red reserved for true failure), and a framed + player error card; cards, buttons, tabs, and inputs get signature hover / focus / + active states. **AA contrast is re-verified and the `tokens.conformance` test + stays green.** + +### Added — in-place auto-update (safe by construction) + +- **Updates land in place over GitHub Releases** — a packaged build checks the feed + on launch and drives an in-app update banner (electron-updater; + download-then-quit-and-install). Because a working install can be bricked by a bad + update, v1.4 lands its safety net **first**: a **single-instance lock** (plus a + data-root-scoped lock so two distinct copies pointed at one relocatable data + folder also mutually exclude) prevents two bootstraps racing into the same env / + `library.db`, and a **version-aware re-bootstrap** gates first-run on a persisted + shipped-requirements **fingerprint** (not just marker existence) so an update that + adds Python deps re-provisions the env instead of starting the sidecar against a + stale one ("No module X"). A version-triggered re-bootstrap is **silent** and + reuses the saved profile — it never re-prompts. + +### Changed — display name & icon unified to "Reframe" + +- **One user-facing name and a real app icon.** The window title, in-app header, + Electron About panel, and installer / Start-menu shortcut all read **"Reframe"**, + and the app ships its production multi-size icon. The About panel reads the + version **dynamically** via `app.getVersion()`, so it tracks the package version + with no hardcoded string. The internal id **`media-studio`** is deliberately + unchanged — the package `name`, the `local.media-studio` appId, the `${name}` + installer-artifact filename, and every appData/path literal keep it so **first-run + state, the data root, proxy/peak/dub caches, and the sidecar-env sentinel survive + the upgrade untouched**. A brand guard test asserts no user-facing surface leaks + "media-studio" / "Media Studio". + +### Legal — NON-COMMERCIAL while ViNet-S is bundled + +- **Reframe v1.4 still bundles the ViNet-S saliency model under CC-BY-NC-SA-4.0.** + ViNet-S (the no-face crop-tracking video-saliency network, ICASSP 2025, + arXiv:2502.00397) is licensed **Attribution-NonCommercial-ShareAlike 4.0**, so + **the app as shipped is NON-COMMERCIAL while that model is bundled**. A future + paid tier MUST remove or replace ViNet-S. Its required attribution — © 2025 + Rohit Girmaji, Siddharth Jain, Bhav Beri, Sarthak Bansal, Vineet Gandhi (IIIT + Hyderabad) — plus the other bundled model licenses (YuNet MIT, EdgeTAM + Apache-2.0, TransNetV2 MIT, LR-ASD MIT) are surfaced **in-app** at + **Settings → Licenses** and in the repo-root **`NOTICE`** file; the vendored + full CC-BY-NC-SA-4.0 text ships at + `sidecar/media_studio/features/_vinet_s/LICENSE`. + +### Rolling back to 1.3.0 + +- **If a 1.4 update ever misbehaves, you can go back.** Download and re-run the + **1.3.0** installer from the + [Releases page](https://github.com/Prekzursil/Reframe/releases) — it installs + over the top and, because the internal id, appId, and every appData/path literal + are unchanged across the upgrade, your **userData and the data root (library, + keys, proxies, caches, installed models) are preserved**. Nothing is migrated + destructively, so a downgrade re-uses the same data folder. + +### Roadmap + +- **A dedicated Reframe Director panel is a v1.5 item.** v1.4 promotes + reframe-to-vertical to a first-class action and wires the existing override + controls; the fuller prompt-driven Reframe Director surface is planned for **1.5**. + +## [1.3.0] — 2026-07-06 + +**Reframe v1.3.0 — branding, encrypted keys, re-hosted models, auto-update.** +Personal / non-commercial build; unsigned (SmartScreen click-through on first launch). + +### Added + +- Unified **"Reframe"** branding + new app icon (fixed the window title still reading + "media-studio"). +- **API keys encrypted at rest** (Windows DPAPI) with reveal + validate in + **Settings → Providers & Keys**. +- **Third-party licenses** screen (**Settings → Licenses**). +- **AI Director onboarding** + a "what works right now" capability matrix. +- **In-place auto-update** via GitHub Releases (`electron-updater`). + +### Fixed + +- **Re-hosted the AI models** (ViNet-S saliency, TransNetV2 scene-cut) — the "unknown + asset" first-launch errors are gone. ## [1.2.0] — 2026-07-03 diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..499f4cb5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,49 @@ +Reframe — Third-Party Notices +============================= + +Reframe bundles the third-party machine-learning models listed below. Their +licenses and required attributions are reproduced here and are also surfaced +in-app at Settings -> Licenses. The full text of the vendored copyleft license +ships alongside the code at the paths noted below. + +NON-COMMERCIAL NOTICE +--------------------- +Reframe v1.3 bundles the ViNet-S saliency model, which is licensed under +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International +(CC-BY-NC-SA-4.0). As a direct consequence, THE APP AS SHIPPED IS +NON-COMMERCIAL for as long as ViNet-S is bundled. A future paid/commercial tier +MUST remove or replace ViNet-S before any commercial distribution. + + +Bundled models +-------------- + +ViNet-S / ViNet — video saliency (no-face crop tracking) + License: CC-BY-NC-SA-4.0 (NON-COMMERCIAL) + https://creativecommons.org/licenses/by-nc-sa/4.0/ + Attribution: (c) 2025 Rohit Girmaji, Siddharth Jain, Bhav Beri, + Sarthak Bansal, Vineet Gandhi (IIIT Hyderabad) + Paper: ViNet-S / ViNet (ICASSP 2025), arXiv:2502.00397 + Source: https://github.com/ViNet-Saliency/vinet_v2 + Full license: sidecar/media_studio/features/_vinet_s/LICENSE + +YuNet — face detector (default speaker tracking) + License: MIT — https://opensource.org/license/mit + Attribution: (c) 2020 Shiqi Yu (opencv/face_detection_yunet) + Source: https://github.com/opencv/opencv_zoo + +EdgeTAM — opt-in occlusion-robust video tracker + License: Apache-2.0 — https://www.apache.org/licenses/LICENSE-2.0 + Attribution: (c) Meta Platforms, Inc. (facebookresearch/EdgeTAM) + Source: https://github.com/facebookresearch/EdgeTAM + +TransNetV2 — shot-transition / scene-cut detector + License: MIT — https://opensource.org/license/mit + Attribution: (c) 2020 Tomas Soucek (soCzech/TransNetV2) + Source: https://github.com/soCzech/TransNetV2 + Full license: sidecar/media_studio/features/_transnetv2/LICENSE + +LR-ASD — visual active-speaker detection + License: MIT — https://opensource.org/license/mit + Attribution: (c) 2025 Liao Junhua (Junhua-Liao/LR-ASD) + Source: https://github.com/Junhua-Liao/LR-ASD diff --git a/README.md b/README.md index ccb5ee6c..1c7fc636 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ and pick one: | Asset | What it is | |-------|------------| -| `media-studio-1.2.0-win-x64.exe` | **NSIS installer** — double-click, choose an install dir, get a Start-menu / desktop shortcut ("Reframe - Media Studio"). | -| `media-studio-1.2.0-win-x64.zip` | **Portable** — unzip anywhere and run `Reframe - Media Studio.exe`. No install, no admin. | +| `media-studio-1.4.1-win-x64.exe` | **NSIS installer** — double-click, choose an install dir, get a Start-menu / desktop shortcut ("Reframe - Media Studio"). Auto-updates in place from here on. | +| `media-studio-1.4.1-win-x64.zip` | **Portable** — unzip anywhere and run `Reframe - Media Studio.exe`. No install, no admin. | **First run does the rest automatically.** The download is **slim** (the app + a bundled CPython + ffmpeg + the render engine). On first launch the app downloads the heavier pieces diff --git a/WIRING-T5.md b/WIRING-T5.md index 8ad633b1..dfdbc628 100644 --- a/WIRING-T5.md +++ b/WIRING-T5.md @@ -113,7 +113,7 @@ app runs `bootstrap.py` BY FILE PATH. If the wiring agent prefers ```text 1. build\check-python.ps1 # CI gate: dev python pinned 3.12 -2. build\python-embed-setup.ps1 -WithFfmpeg # NETWORK: stages build/python-embed + build/ffmpeg +2. build\python-embed-setup.ps1 -WithFfmpeg # NETWORK: stages build/python-embed + build/ffmpeg/win (BtbN LGPL) 3. cd app && npm run render-cli:install # WIRING-T4A §2 script hooks 4. cd app && npm run build && npm run render-cli:bundle 5. cd app && npx electron-builder --config ..\electron-builder.yml --win diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 4619ea29..ff084fe1 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -12,8 +12,8 @@ // drive headlessly. Seeding the data root the sidecar reads is equivalent — the // app lists + opens + plays the exact same library record either way. -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, existsSync, readdirSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, existsSync, readdirSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -210,7 +210,14 @@ function sidecarCall(python: string, dataRoot: string, method: string, params: u */ export function seedEnvironment(): SeededEnv { const python = resolvePython(); - const dataRoot = mkdtempSync(join(tmpdir(), 'reframe-e2e-data-')); + // realpathSync.NATIVE expands the tmp path's 8.3 SHORT name to its LONG form. + // os.tmpdir() on Windows returns the SHORT name (C:\Users\PREKZU~1\...), which + // becomes main's DATA_ROOT — but the sidecar canonicalises its thumbnail path to + // the LONG form (C:\Users\Prekzursil\...). The mstream `thumb:` containment guard + // (startsWith) then rejects the poster (short root vs long path) → a 404. Plain + // realpathSync does NOT expand short names (verified); .native does. A real + // install's %APPDATA% root has no short/long split; canonicalising here matches it. + const dataRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'reframe-e2e-data-'))); const samplePath = join(dataRoot, 'sample.mp4'); generateSample(samplePath); @@ -219,6 +226,14 @@ export function seedEnvironment(): SeededEnv { }; const videoId = added.video.id; + // Generate the source poster frame so the Library card's `thumb:` request resolves + // instead of 404-ing in the sandboxed renderer. `library.thumbnail` is a SYNC RPC + // (verified: it runs ffmpeg and writes data_dir/thumbnails/.jpg before + // returning), so the one-shot sidecarCall completes the write. The app does this + // on demand (useVideoThumbnail); an out-of-band seed must do it too. Not swallowed: + // a thumbnail failure is a real setup problem worth surfacing, not hiding. + sidecarCall(python, dataRoot, 'library.thumbnail', { id: videoId }); + const appEnv = definedEnv({ MEDIA_STUDIO_PYTHON: python, MEDIA_STUDIO_SIDECAR_DIR: SIDECAR_DIR, @@ -228,6 +243,91 @@ export function seedEnvironment(): SeededEnv { return { dataRoot, samplePath, videoId, python, appEnv }; } +/** + * Provision assets (a LONG job) by driving a live sidecar until the ensure job + * reports `job.done`. Unlike `sidecarCall` (one-shot spawnSync, which would kill + * the download when the RPC returns), this keeps the sidecar alive and streams its + * stdout until the `assets.ensure` job completes. Used to seed the core reframe + * model (YuNet) into the data root — the SAME step a real first-run performs — + * so the default `reframeEngine:"auto"` (claudeshorts) path can actually reframe. + */ +export function provisionAssets(python: string, dataRoot: string, names: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(python, ['-m', 'media_studio'], { + cwd: SIDECAR_DIR, + env: { ...process.env, MEDIA_STUDIO_CONFIG_DIR: dataRoot }, + }); + let buf = ''; + let jobId = ''; + // Capture the sidecar's stderr so a SILENT provisioning failure (e.g. a missing + // download dep -> assets.ensure gracefully SKIPS the item but still reports + // job.done) is surfaced in the rejection instead of only manifesting later as a + // mysterious "model not provisioned" at reframe time. + let stderr = ''; + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + const timer = setTimeout(() => { + proc.kill(); + reject(new Error(`provisionAssets(${names.join(',')}) timed out\n${stderr}`)); + }, 300_000); + const done = (err?: Error): void => { + clearTimeout(timer); + proc.kill(); + if (err) reject(err); + else resolve(); + }; + proc.stdout.setEncoding('utf8'); + proc.stdout.on('data', (chunk: string) => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line.startsWith('{')) continue; + let msg: { + id?: number; + method?: string; + result?: { jobId?: string }; + params?: { jobId?: string; result?: { installed?: string[] } }; + }; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg.id === 1 && msg.result?.jobId) jobId = msg.result.jobId; + if (msg.params?.jobId === jobId && msg.method === 'job.done') { + // VERIFY the requested assets actually installed. assets.ensure emits + // job.done even when it GRACEFULLY SKIPS a failed item (WU C1), so a silent + // download failure would otherwise pass as success here and only surface + // as "model not provisioned" deep in the reframe stage. + const installed = msg.params.result?.installed ?? []; + const missing = names.filter((n) => !installed.includes(n)); + if (missing.length > 0) { + done( + new Error( + `provisionAssets: ${missing.join(', ')} did not install ` + + `(installed=${JSON.stringify(installed)}); sidecar stderr:\n${stderr}`, + ), + ); + } else { + done(); + } + } + if (msg.params?.jobId === jobId && msg.method === 'job.error') { + done(new Error(`provisionAssets job.error: ${JSON.stringify(msg.params)}\n${stderr}`)); + } + } + }); + proc.on('error', reject); + proc.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'assets.ensure', params: { names } })}\n`, + ); + }); +} + /** Direct `media.playable` probe (used to label the preview path honestly). */ export function probePlayable( python: string, diff --git a/app/e2e/golden-journey.spec.ts b/app/e2e/golden-journey.spec.ts new file mode 100644 index 00000000..27c6ecb5 --- /dev/null +++ b/app/e2e/golden-journey.spec.ts @@ -0,0 +1,276 @@ +// golden-journey.spec.ts — the KEYSTONE held-out acceptance test for Reframe. +// +// This is the single external "done-signal" that proves the app actually WORKS +// A→B→C — coverage + unit-green are necessary-but-not-sufficient; THIS drives the +// real "Make Shorts" user journey through the LIVE Electron GUI + the LIVE Python +// sidecar and asserts a REAL produced vertical short file exists on disk. +// +// The journey (everything against the live app + live sidecar — nothing stubbed): +// launch built app +// → the seeded 'sample' video is imported + listed in the Library +// → navigate to the top-level "Make Shorts" tab (routes to makeshorts) +// → select 'sample' in the Make-Shorts front-door picker (opens it for +// short-making — this section owns its OWN picker; see MakeShorts.tsx §h) +// → drive the MANUAL-interval path (add one 0:00→0:02 range, then +// "Make shorts from ranges") — the deterministic path: it feeds inline +// Candidates straight to shortmaker.export with NO ML moment-pick, NO +// transcript, and (unlike select/boundary) is NOT re-clamped to the 20-60s +// hard window, so the real 3s seeded sample can genuinely produce a clip +// (AI moment-pick would emit "no candidates" on a 3s no-speech sample) +// → the UI acknowledges the dispatched export (the "Exported N clip(s)" note) +// → wait for the produced short to appear in the produced-shorts listing +// (shorts.list — the exact RPC the gallery renders from), read the FINAL +// clip's real output path, and assert that file EXISTS on disk + is +// non-empty. Additionally assert it is a VERTICAL short (height > width, +// from the sidecar's own ffprobe dims) with a real (>0) duration. +// +// DONE-SIGNAL note: the manual path fires the export as a background Job (the RPC +// resolves with {jobId} immediately — the note appears BEFORE render finishes), +// so completion is proven by polling shorts.list for the FINAL clip. Every +// exported clip writes a sidecar .json (videoId/durationSec/…) that +// shorts.list reconstructs ShortInfo from; the pipeline's INTERMEDIATES +// (*.cut.mp4 / *.reframed.mp4 / …) live in the same dir but carry NO .json, so +// they surface with videoId='' — we filter for `videoId === seeded.videoId` +// (metadata present ⇒ the FULL cut→reframe→caption→export pipeline finished), +// which uniquely identifies the finished vertical short. +// +// It is EXPECTED + CORRECT for this test to RED-REPRO if the export pipeline is +// broken ("shorts don't actually generate"): if the job errors mid-pipeline no +// metadata-bearing short is ever written, so the poll times out with a diagnostic +// snapshot of what DID land on disk (which intermediate stage was reached). A +// faithful red repro is a success here; a green fantasy is a failure. + +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; +import { existsSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { findBuiltApp, provisionAssets, seedEnvironment, type SeededEnv } from './fixtures'; + +let seeded: SeededEnv; +let app: ElectronApplication; +const consoleErrors: string[] = []; +const failedRequests: string[] = []; +// Buffer the packaged/dev MAIN process stdout+stderr — the spawned sidecar's +// export-job errors (e.g. a reframe ClaudeShortsBackendUnavailableError, a cv2 +// FaceDetectorYN failure, a missing model) surface there, NOT in Playwright's +// error-context.md. We fold the tail into the done-signal failure message so a +// red repro names the stage that broke instead of only the on-disk snapshot. +const mainLog: string[] = []; + +/** The subset of ShortInfo (§3, features/shorts.py) this test reads off shorts.list. */ +interface BridgeShort { + path: string; + videoId: string; + durationSec: number; + width: number; + height: number; +} + +/** + * Read `shorts.list {videoId}` through the LIVE preload bridge (window.api, the + * same RPC the produced-shorts gallery renders from) so we observe the exact + * sidecar the export job ran against. Returns [] on any bridge/RPC hiccup so the + * caller's poll simply retries rather than throwing mid-render. + */ +async function listShortsViaBridge(win: Page, videoId: string): Promise { + return win.evaluate(async (vid: string): Promise => { + try { + const api = ( + window as unknown as { + api?: { rpc: (method: string, params?: unknown) => Promise<{ shorts?: BridgeShort[] }> }; + } + ).api; + if (!api) return []; + const res = await api.rpc('shorts.list', { videoId: vid }); + const shorts = res?.shorts; + return Array.isArray(shorts) ? shorts : []; + } catch { + return []; + } + }, videoId); +} + +/** + * Poll shorts.list until a FINISHED short for `videoId` appears (metadata-bearing + * ⇒ the full pipeline completed) or the deadline passes. Returns the final clip + * plus the last snapshot (the intermediates on disk) for a diagnostic red repro. + */ +async function pollForFinalShort( + win: Page, + videoId: string, + deadlineMs: number, +): Promise<{ final: BridgeShort | null; snapshot: BridgeShort[] }> { + let snapshot: BridgeShort[] = []; + while (Date.now() < deadlineMs) { + snapshot = await listShortsViaBridge(win, videoId); + const final = snapshot.find((s) => s.videoId === videoId && s.durationSec > 0); + if (final) return { final, snapshot }; + await new Promise((r) => setTimeout(r, 1500)); + } + return { final: null, snapshot }; +} + +test.beforeAll(async () => { + // macOS is a NON-SHIPPING platform: electron-builder ships a `win:` target only + // (packaged.spec + build/python-embed-setup.ps1 are Windows-only). golden-journey + // runs the FULL export pipeline through the libass caption stage, and brew-ffmpeg + // on the macOS CI runner rejects the `subtitles=` filter path-quoting that the + // Linux (apt) + Windows (choco) ffmpeg both accept — a macOS-runner ffmpeg quirk, + // not a product defect. The full-pipeline keystone therefore runs on Windows (the + // shipping platform) + Linux; preview.spec still gives the macOS leg GUI coverage. + test.skip( + process.platform === 'darwin', + 'non-shipping platform (electron-builder win: only) + brew-ffmpeg subtitles-filter path-quoting quirk', + ); + // Prefer the SHIPPED package (real .exe on Windows); fall back to the dev build + // so this spec still gives local GUI coverage (see fixtures.findBuiltApp). + const built = findBuiltApp(); + seeded = seedEnvironment(); + // Provision the core reframe model (YuNet) into the data root — the SAME step a + // real first-run performs — so the default reframeEngine:"auto" (claudeshorts) + // path can actually reframe. Without it, "auto" raises ClaudeShortsBackend- + // UnavailableError and no short is ever produced (a provisioning gap, not a bug). + await provisionAssets(seeded.python, seeded.dataRoot, ['yunet-face-detection']); + app = await electron.launch({ + args: [ + built.main, + // No user gesture exists in an automated launch; allow media to start. + '--autoplay-policy=no-user-gesture-required', + '--no-sandbox', + ], + ...(built.executablePath ? { executablePath: built.executablePath } : {}), + env: seeded.appEnv, + }); + + // Capture the main process (and its spawned sidecar) stdout/stderr so a failed + // export job's real error is recoverable in CI (see mainLog note above). + const proc = app.process(); + proc.stdout?.on('data', (d: Buffer) => mainLog.push(d.toString())); + proc.stderr?.on('data', (d: Buffer) => mainLog.push(d.toString())); + + // Bind the console/pageerror collectors from the very first frame so they cover + // the WHOLE session (load + navigation + making the short). + const win = await app.firstWindow(); + win.on('console', (m) => { + if (m.type() === 'error') consoleErrors.push(m.text()); + }); + win.on('pageerror', (e) => consoleErrors.push(`PAGEERROR: ${e.message}`)); + // Chromium's "Failed to load resource: 404" console line omits the URL — capture + // the failing response's URL + status here so a resource error is self-diagnosing + // (which asset 404'd) instead of an anonymous console string. + win.on('response', (r) => { + const s = r.status(); + if (s >= 400) failedRequests.push(`${s} ${r.url()}`); + }); + win.on('requestfailed', (req) => { + failedRequests.push(`FAILED ${req.url()} (${req.failure()?.errorText ?? 'unknown'})`); + }); + await win.waitForLoadState('domcontentloaded'); +}); + +test.afterAll(async () => { + await app?.close(); +}); + +test('Make Shorts produces a real vertical short file on disk (golden journey)', async () => { + // Rendering a short runs cut → reframe(1080x1920) → caption → export through + // real ffmpeg; give the pipeline generous headroom over the 120s config default. + test.setTimeout(240_000); + const win = await app.firstWindow(); + + // A — the shell is up and the seeded sample is imported + listed (openable). + await expect(win.locator('.app__brand')).toHaveText('Reframe'); + await expect(win.locator('.library__item-title').first()).toHaveText('sample'); + + // B — navigate to the top-level "Make Shorts" section (routes to makeshorts). + // NOTE: the tab's real label is "Make Shorts" (App.tsx → TopTabBar renders + // tab.label); preview.spec's `hasText: 'Create'` predates the relabel. + await win.locator('.toptab', { hasText: 'Make Shorts' }).click(); + await expect( + win.locator('.toptab[aria-selected="true"]', { hasText: 'Make Shorts' }), + ).toBeVisible(); + await expect(win.locator('.make-shorts__make')).toBeVisible(); + + // Select 'sample' in the Make-Shorts front-door picker. This section owns its + // OWN video picker (novice front door, MakeShorts.tsx §h): selecting the video + // here IS "opening" it for short-making — the top tab does not thread an open + // Workspace video, and driving the picker keeps the journey deterministic + // (no Workspace proxy-build side effects to race against the export). + const picker = win.locator('select[aria-label="Source video"]'); + await expect(picker.locator('option', { hasText: 'sample' })).toBeAttached({ timeout: 15_000 }); + await picker.selectOption(seeded.videoId); + + // Selecting a video reveals the making surfaces, including manual intervals. + const manual = win.locator('.make-shorts__manual'); + await expect(manual).toBeVisible(); + + // C — MANUAL path: add one explicit 0:00 → 0:02 range (well inside the 3s + // sample), then "Make shorts from ranges" to trigger the real shortmaker.export. + await manual.locator('input[aria-label="Range start"]').fill('0:00'); + await manual.locator('input[aria-label="Range end"]').fill('0:02'); + await manual.locator('button', { hasText: 'Add range' }).click(); + await expect(manual.locator('.manual-interval__range')).toHaveCount(1); + await manual.locator('.manual-interval__make', { hasText: 'Make shorts from ranges' }).click(); + + // The UI acknowledges the DISPATCH (note appears once the export RPC returns a + // jobId — the render then runs in the background). Surface a dispatch error + // (invalid params / dead sidecar) as the failure instead of a silent timeout. + await win + .locator('.make-shorts__note, .make-shorts__error') + .first() + .waitFor({ state: 'visible', timeout: 30_000 }); + if (await win.locator('.make-shorts__error').isVisible()) { + throw new Error( + `manual export dispatch failed: ${(await win.locator('.make-shorts__error').textContent())?.trim()}`, + ); + } + await expect(win.locator('.make-shorts__note')).toContainText('Exported'); + + // PRIMARY DONE-SIGNAL — wait for the FINISHED short, then assert the real file. + const { final, snapshot } = await pollForFinalShort(win, seeded.videoId, Date.now() + 200_000); + // On a red repro, surface the sidecar's own error tail (the export job's real + // failure — reframe/cv2/model) alongside the on-disk snapshot so the failing + // stage is named, not guessed. + const sidecarErr = mainLog + .join('') + .split(/\r?\n/) + .filter((l) => + /error|traceback|reframe|yunet|cv2|facedetector|claudeshorts|opencv|backend|provision|not provisioned/i.test(l), + ) + .slice(-30) + .join('\n'); + expect( + final, + `no finished short appeared for videoId=${seeded.videoId} within 200s — the export ` + + `job never completed (shorts.list snapshot, incl. intermediates: ${JSON.stringify(snapshot)})` + + (sidecarErr ? `\n--- sidecar error tail ---\n${sidecarErr}` : ''), + ).not.toBeNull(); + + const producedPath = resolve(final!.path); + expect(existsSync(producedPath), `produced short exists on disk: ${producedPath}`).toBe(true); + expect( + statSync(producedPath).size, + `produced short is a non-empty file: ${producedPath}`, + ).toBeGreaterThan(0); + + // A real VERTICAL short with a real duration (dims from the sidecar's own + // ffprobe on the finished mp4; the pipeline only writes metadata after a + // successful 1080x1920 reframe, so a metadata-bearing clip is genuinely portrait). + expect( + final!.height, + `portrait short (height > width) — got ${final!.width}x${final!.height}`, + ).toBeGreaterThan(final!.width); + expect(final!.width, 'produced short has real dimensions').toBeGreaterThan(0); + expect(final!.durationSec, 'produced short has a real duration').toBeGreaterThan(0); +}); + +test('no console errors across the golden-journey session', async () => { + // The console/pageerror listeners (bound in beforeAll) collected across load, + // navigation, picker selection, and the whole make-a-short flow. Kept SEPARATE + // from — and after — the file-on-disk done-signal so a renderer console error + // never masks the primary "did a real short get produced?" verdict. + expect( + consoleErrors, + `console errors across session: ${JSON.stringify(consoleErrors)}\n` + + `failed requests (URLs behind any "Failed to load resource" line): ${JSON.stringify(failedRequests)}`, + ).toEqual([]); +}); diff --git a/app/e2e/packaged.spec.ts b/app/e2e/packaged.spec.ts index 41294df6..732725e5 100644 --- a/app/e2e/packaged.spec.ts +++ b/app/e2e/packaged.spec.ts @@ -81,33 +81,38 @@ test.describe('packaged (shipped binary) E2E', () => { expect(appPath.replace(/\\/g, '/').toLowerCase()).toContain('resources'); }); - test('the packaged renderer boots with no console errors and shows the live UI', async () => { + test('the packaged renderer boots and shows the first-run setup UI (not a blank screen)', async () => { const win = await app.firstWindow(); win.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); win.on('pageerror', (e) => consoleErrors.push(`PAGEERROR: ${e.message}`)); await win.waitForLoadState('domcontentloaded'); - // The brand renders from the PACKAGED renderer bundle, driven by the bundled - // Python sidecar (library + readiness rollup settle after the boot RPCs). - await expect(win.locator('.app__brand')).toHaveText('Reframe - Media Studio'); - await expect(win.locator('.library__title')).toHaveText('Library'); - await win.waitForTimeout(1500); + // A COLD packaged first-run pip-installs the heavy sidecar runtime into + // /envs/sidecar (multi-minute, network-bound) BEFORE the sidecar can + // answer RPCs, so the post-provisioning shell (.app__brand / Library — they settle + // only after the boot RPCs) legitimately cannot appear inside a CI window. What the + // packaged renderer DOES render is the full-screen FirstRunSetup gate, driven by the + // MAIN-process getProvisioningState (available while the sidecar installs — see + // useFirstRunSetup), so asserting it proves the packaged renderer bundle BOOTS and + // shows a LIVE setup UI rather than a blank/white screen. The real post-provisioning + // shell + full pipeline are proven on the dev build (preview.spec, every leg) and by + // the clean-box first-run smoke (app/e2e/README) — not this 30s CI window. + await expect(win.locator('.first-run-setup')).toBeVisible({ timeout: 30_000 }); + await win.waitForTimeout(1000); expect(consoleErrors, `console errors: ${JSON.stringify(consoleErrors)}`).toEqual([]); }); - test('the packaged main process wires the sidecar with the seeded env + first-run bootstrap', async () => { - // FINDING (proven by the captured main log, not inferred): a FRESH packaged - // launch correctly inherits our seeded env (MEDIA_STUDIO_CONFIG_DIR/PYTHON/ - // SIDECAR_DIR) and then enters the documented FIRST-RUN BOOTSTRAP — it - // pip-installs the heavy sidecar runtime into /envs/sidecar before - // the sidecar can answer RPCs (electron-builder.yml ships only SOURCE + - // embeds; the heavy wheels install on first run). That install is multi-minute - // and network-bound, so a cold packaged data-pipeline (ping/library.list/ - // playback/export) cannot complete inside a CI test window. Those pipeline - // assertions therefore run against the dev build (preview.spec on every leg); - // here we prove the packaged SHELL is correctly wired and the bootstrap the - // shipped app depends on actually fires from the .exe. See app/e2e/README.md. + test('the packaged main process is wired with the seeded env + enters its first-run bootstrap', async () => { + // A FRESH packaged launch correctly inherits our seeded env (MEDIA_STUDIO_CONFIG_DIR/ + // PYTHON/SIDECAR_DIR) and then enters the documented FIRST-RUN BOOTSTRAP — it + // pip-installs the heavy sidecar runtime into /envs/sidecar before the + // sidecar can answer RPCs (electron-builder ships only SOURCE + embeds; the heavy + // wheels install on first run). That install is multi-minute + network-bound, so its + // COMPLETION (bootstrap → sidecar → the ping/library/playback/export pipeline) cannot + // finish inside a CI window — that end-to-end packaged first-run is verified by the + // clean-box first-run smoke (app/e2e/README), and the pipeline itself by the dev-build + // preview.spec on every leg. Here we prove the two things CI can: const mainEnv = await app.evaluate(() => ({ configDir: process.env.MEDIA_STUDIO_CONFIG_DIR ?? null, python: process.env.MEDIA_STUDIO_PYTHON ?? null, @@ -119,20 +124,16 @@ test.describe('packaged (shipped binary) E2E', () => { `packaged main must inherit MEDIA_STUDIO_CONFIG_DIR (env=${JSON.stringify(mainEnv)})`, ).toBe(seeded.dataRoot); - // (b) the packaged main actually started the first-run bootstrap from the - // shipped resources/sidecar — i.e. the .exe is wired to bring up its backend - // (rather than silently doing nothing). Poll the captured main/sidecar log. - const sawBootstrap = async (): Promise => { - for (let i = 0; i < 30; i++) { - if (mainLog.join('').includes('[bootstrap]')) return true; - await new Promise((r) => setTimeout(r, 1000)); - } - return false; - }; - expect( - await sawBootstrap(), - `packaged main must run first-run bootstrap from the shipped backend.` + + // (b) the packaged .exe brought up its first-run provisioning flow: the FirstRunSetup + // gate is driven by the MAIN-process getProvisioningState (active while the sidecar + // installs — see useFirstRunSetup), so a visible gate proves the bootstrap the shipped + // app depends on actually fired from the .exe — a renderer-observable signal that + // survives the CI window, unlike waiting for the multi-minute install to finish. + const win = await app.firstWindow(); + await expect( + win.locator('.first-run-setup'), + `packaged .exe must enter its first-run bootstrap gate.` + `\n--- packaged main/sidecar log ---\n${mainLog.join('').slice(-4000)}`, - ).toBe(true); + ).toBeVisible({ timeout: 30_000 }); }); }); diff --git a/app/e2e/preview.spec.ts b/app/e2e/preview.spec.ts index 9fe2c92d..efd663a3 100644 --- a/app/e2e/preview.spec.ts +++ b/app/e2e/preview.spec.ts @@ -53,7 +53,7 @@ test('renderer loads with no console errors', async () => { }); win.on('pageerror', (e) => consoleErrors.push(`PAGEERROR: ${e.message}`)); await win.waitForLoadState('domcontentloaded'); - await expect(win.locator('.app__brand')).toHaveText('Reframe - Media Studio'); + await expect(win.locator('.app__brand')).toHaveText('Reframe'); // Let the library list + readiness rollup settle (RPCs to the live sidecar). await win.waitForTimeout(1500); expect(consoleErrors, `console errors: ${JSON.stringify(consoleErrors)}`).toEqual([]); @@ -75,12 +75,14 @@ test('Library panel mounts and shows the imported sample', async () => { await expect(win.locator('.library__item-title').first()).toHaveText('sample'); }); -test('Create (Shorts) panel mounts via the top-level tabs', async () => { +test('Make Shorts panel mounts via the top-level tabs', async () => { const win = await app.firstWindow(); - await win.locator('.toptab', { hasText: 'Create' }).click(); - // The Create tab becomes the selected top-level tab. + // v1.4 renamed the shorts-making top tab "Create" -> "Make Shorts" (App.tsx + // makeshorts nav label). Drive the current label so this stops timing out. + await win.locator('.toptab', { hasText: 'Make Shorts' }).click(); + // The Make Shorts tab becomes the selected top-level tab. await expect( - win.locator('.toptab[aria-selected="true"]', { hasText: 'Create' }), + win.locator('.toptab[aria-selected="true"]', { hasText: 'Make Shorts' }), ).toBeVisible(); // Return to Library for the Workspace test. await win.locator('.toptab', { hasText: 'Library' }).click(); @@ -95,8 +97,11 @@ test('preview