From 41f190f41bd3a5d474c3143aadf57d4258d9bf56 Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Fri, 23 Oct 2026 04:58:28 +0000 Subject: [PATCH 1/3] fix: give navigation and content one coherent app viewport --- web/src/App.tsx | 30 +++-- web/src/components/discovery-dashboard.css | 2 +- web/src/components/navigation.css | 13 +- web/src/components/search-page.tsx | 1 + web/src/components/settings-page.tsx | 25 ++-- .../components/spectrumui/infinite-scroll.tsx | 10 +- web/src/lib/app-scroll.ts | 4 + web/src/lib/viewport.ts | 26 ++-- web/src/main.tsx | 4 +- web/src/styles.css | 34 ++++- web/tests/workflow.spec.ts | 120 +++++++++++++----- 11 files changed, 189 insertions(+), 80 deletions(-) create mode 100644 web/src/lib/app-scroll.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 95c4328..9cb1931 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { type RefObject, } from "react"; import { useAuth } from "./auth"; +import { appScroller } from "./lib/app-scroll"; import type Hls from "hls.js"; import hlsWorkerUrl from "hls.js/dist/hls.worker.js?url"; import { @@ -321,7 +322,7 @@ function App() { return false; originScrollToRestore.current = null; scrolledExpansion.current = selectionRef.current; - window.scrollTo({ top, behavior: "instant" }); + appScroller().scrollTo({ top, behavior: "instant" }); const target = area.querySelector(".play-action:not(:disabled)") || area.querySelector("h1"); @@ -512,7 +513,7 @@ function App() { setEpisodeDestination(next.episodeDestination); setSelected((old) => (old?.id === next.item ? old : null)); window.requestAnimationFrame(() => - window.scrollTo({ + appScroller().scrollTo({ behavior: "instant", top: next.player ? 0 @@ -846,7 +847,7 @@ function App() { .querySelector('input[aria-label="Search library"]') ?.focus(), ); - window.scrollTo({ top: 0 }); + appScroller().scrollTo({ top: 0 }); } else if (next === "dashboard") { if (dashboardOpen) return; @@ -859,15 +860,15 @@ function App() { setPlaybackDetailsId(""); setEpisodeDestination(null); setDetailError(""); - window.scrollTo({ top: 0 }); + appScroller().scrollTo({ top: 0 }); } else if (next === "settings") { if (settingsOpen) return; - libraryScroll.current = window.scrollY; + libraryScroll.current = appScroller().scrollTop; writeLocation(tab, category, search, false, selectedId, true); setSettingsOpen(true); setDashboardOpen(false); setSearchOpen(false); - window.scrollTo({ top: 0 }); + appScroller().scrollTo({ top: 0 }); } else selectCategory(next); }; const openDetails = ( @@ -964,7 +965,11 @@ function App() { ).href; window.history.replaceState(window.history.state, "", url); } - playerOrigin.current = { url, initialURL: url, scroll: window.scrollY }; + playerOrigin.current = { + url, + initialURL: url, + scroll: appScroller().scrollTop, + }; } const originSelection = playerOrigin.current ? locationSelection(playerOrigin.current.url) @@ -1261,7 +1266,7 @@ function App() { episodeTarget, ); } else { - window.scrollTo({ top: 0, behavior: "smooth" }); + appScroller().scrollTo({ top: 0, behavior: "smooth" }); } }; const sectionCategories = useMemo( @@ -1287,7 +1292,7 @@ function App() { } if (element) opener.current = element; if (searchContext) setTab(item.kind as Tab); - libraryScroll.current = window.scrollY; + libraryScroll.current = appScroller().scrollTop; scrolledExpansion.current = ""; setDetailError(""); @@ -1436,10 +1441,10 @@ function App() { ? lastVodTab.current : destination; navigateSection(next); - window.scrollTo({ top: 0 }); + appScroller().scrollTo({ top: 0 }); }} /> -
+
{providerSetupRequired && !settingsOpen && ( @@ -1585,7 +1590,7 @@ function App() { now={now} onBrowse={(kind) => { selectCategory(kind); - window.scrollTo({ top: 0 }); + appScroller().scrollTo({ top: 0 }); }} onOpen={openHistory} onResume={resumeDashboardTitle} @@ -1891,6 +1896,7 @@ function App() { (tab === "live" && items.length > visibleCount) || (tab !== "live" && hasMore && !browseError)) && ( { diff --git a/web/src/components/settings-page.tsx b/web/src/components/settings-page.tsx index 0886dba..649e6b1 100644 --- a/web/src/components/settings-page.tsx +++ b/web/src/components/settings-page.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { Fingerprint, RefreshCw, SlidersHorizontal } from "lucide-react"; import { useAuth } from "../auth"; +import { appScroller } from "../lib/app-scroll"; import { getSettings, updateSettings, @@ -130,6 +131,7 @@ export function SettingsPage({ }; useEffect(() => { if (canAdmin && !settings) return; + const scroller = appScroller(); let frame = 0; const update = () => { frame = 0; @@ -143,13 +145,17 @@ export function SettingsPage({ let current = sections[0].id; for (const section of sections) { const element = document.getElementById(section.id); - if (element && element.getBoundingClientRect().top <= offset) + if ( + element && + element.getBoundingClientRect().top <= + scroller.getBoundingClientRect().top + offset + ) current = section.id; } if ( - window.scrollY > 0 && - Math.ceil(window.scrollY + window.innerHeight) >= - document.documentElement.scrollHeight - 2 + scroller.scrollTop > 0 && + Math.ceil(scroller.scrollTop + scroller.clientHeight) >= + scroller.scrollHeight - 2 ) // Short final sections share a clamped scroll position. current = sections.at(-1)!.id; @@ -163,11 +169,7 @@ export function SettingsPage({ schedule(); }; const clearScrollbarJump = (event: PointerEvent) => { - if ( - event.target === document.documentElement || - event.target === document.body - ) - clearJump(); + if (event.target === scroller) clearJump(); }; const scrollKey = (event: KeyboardEvent) => { if (event.defaultPrevented) return; @@ -197,7 +199,7 @@ export function SettingsPage({ ) clearJump(); }; - window.addEventListener("scroll", schedule, { passive: true }); + scroller.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", clearJump); window.addEventListener("wheel", clearJump, { passive: true }); window.addEventListener("touchmove", clearJump, { passive: true }); @@ -207,10 +209,11 @@ export function SettingsPage({ window.addEventListener("keydown", scrollKey); const observer = new ResizeObserver(schedule); if (pageRef.current) observer.observe(pageRef.current); + observer.observe(scroller); schedule(); return () => { cancelAnimationFrame(frame); - window.removeEventListener("scroll", schedule); + scroller.removeEventListener("scroll", schedule); window.removeEventListener("resize", clearJump); window.removeEventListener("wheel", clearJump); window.removeEventListener("touchmove", clearJump); diff --git a/web/src/components/spectrumui/infinite-scroll.tsx b/web/src/components/spectrumui/infinite-scroll.tsx index 8fbe442..a8da045 100644 --- a/web/src/components/spectrumui/infinite-scroll.tsx +++ b/web/src/components/spectrumui/infinite-scroll.tsx @@ -9,6 +9,7 @@ interface InfiniteScrollProps { hasMore: boolean; next: () => void; children: React.ReactNode; + scrollRoot?: string; } export default function InfiniteScroll({ @@ -16,6 +17,7 @@ export default function InfiniteScroll({ hasMore, next, children, + scrollRoot, }: InfiniteScrollProps) { const observer = React.useRef(null); const observerRef = React.useCallback( @@ -31,12 +33,16 @@ export default function InfiniteScroll({ observer.current = null; next(); }, - { threshold: 0, root: null, rootMargin: "300px 0px" }, + { + threshold: 0, + root: scrollRoot ? element.closest(scrollRoot) : null, + rootMargin: "300px 0px", + }, ); observer.current = current; current.observe(element); }, - [hasMore, isLoading, next], + [hasMore, isLoading, next, scrollRoot], ); React.useEffect(() => () => observer.current?.disconnect(), []); const items = React.Children.toArray(children); diff --git a/web/src/lib/app-scroll.ts b/web/src/lib/app-scroll.ts new file mode 100644 index 0000000..603ce12 --- /dev/null +++ b/web/src/lib/app-scroll.ts @@ -0,0 +1,4 @@ +/** The app owns one content scroller; navigation stays outside it. */ +export function appScroller() { + return document.getElementById("app-scroll")!; +} diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts index bf46b1f..e83afd5 100644 --- a/web/src/lib/viewport.ts +++ b/web/src/lib/viewport.ts @@ -1,5 +1,5 @@ -/** Recover Chrome on iPad retaining its portrait viewport after fullscreen. */ -export function trackViewportRecovery() { +/** Contain Chrome iOS fullscreen viewport inconsistencies at the app boundary. */ +export function trackAppViewport() { const viewport = document.querySelector( 'meta[name="viewport"]', ); @@ -8,8 +8,9 @@ export function trackViewportRecovery() { const original = viewport.content; const root = document.documentElement; let appliedWidth = 0; - let timer: number | undefined; + let frame = 0; const update = () => { + frame = 0; const width = window.outerWidth; const scale = window.visualViewport?.scale ?? 1; // iOS can fit the previous explicit width to a smaller window on rotation. @@ -26,11 +27,15 @@ export function trackViewportRecovery() { ) return; - // The layout height can stay at 1499px while only 682px is visible, even - // after the width recovers. Keep fixed navigation inside the visible area. + // The shell occupies the visible viewport. Its content scrolls independently + // of the stale layout viewport, so navigation and content share one frame. const height = window.visualViewport?.height ?? window.innerHeight; if (height > 0) - root.style.setProperty("--sidebar-viewport-height", `${height}px`); + root.style.setProperty("--app-viewport-height", `${height}px`); + root.style.setProperty( + "--app-viewport-top", + `${window.visualViewport?.offsetTop ?? 0}px`, + ); if ( (!appliedWidth || appliedWidth === width) && Math.abs(window.innerWidth - width) <= 1 @@ -47,8 +52,7 @@ export function trackViewportRecovery() { ); }; const schedule = () => { - window.clearTimeout(timer); - timer = window.setTimeout(update, 300); + if (!frame) frame = window.requestAnimationFrame(update); }; const listeners = new AbortController(); const options = { signal: listeners.signal }; @@ -61,12 +65,14 @@ export function trackViewportRecovery() { ]) document.addEventListener(event, schedule, { ...options, capture: true }); window.visualViewport?.addEventListener("resize", schedule, options); + window.visualViewport?.addEventListener("scroll", schedule, options); schedule(); return () => { listeners.abort(); - window.clearTimeout(timer); - root.style.removeProperty("--sidebar-viewport-height"); + window.cancelAnimationFrame(frame); + root.style.removeProperty("--app-viewport-height"); + root.style.removeProperty("--app-viewport-top"); if (appliedWidth) viewport.content = original; }; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 1bd0be1..98f1897 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,12 +3,12 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import { AuthGate } from "./auth"; import { trackInputModality } from "./lib/input-modality"; -import { trackViewportRecovery } from "./lib/viewport"; +import { trackAppViewport } from "./lib/viewport"; import "@fontsource-variable/manrope"; import "./styles.css"; const stopTrackingInput = trackInputModality(); -const stopTrackingViewport = trackViewportRecovery(); +const stopTrackingViewport = trackAppViewport(); if (import.meta.hot) import.meta.hot.dispose(() => { stopTrackingInput(); diff --git a/web/src/styles.css b/web/src/styles.css index 6d9b424..287d275 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -65,6 +65,9 @@ body { min-height: 100dvh; background: #0c0d10; } +body:has(.app-shell) { + overflow: hidden; +} button { cursor: pointer; } @@ -131,7 +134,7 @@ p { } .skip-link { visibility: hidden; - position: fixed; + position: absolute; top: 10px; left: 50%; z-index: 100; @@ -148,12 +151,26 @@ p { transform: translate(-50%, 0); } .app-shell { - position: relative; + position: fixed; + top: var(--app-viewport-top, 0px); + right: 0; + left: 0; + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + height: var(--app-viewport-height, 100dvh); + overflow: hidden; isolation: isolate; - min-height: 100dvh; } .library-layout { - min-height: 100dvh; + grid-column: 2; + grid-row: 1; + min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + scroll-behavior: smooth; + scroll-padding-top: 24px; } .main-content { min-width: 0; @@ -1244,9 +1261,16 @@ p { --page-gutter: 20px; --sidebar-width: 0px; } + .app-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + } + .library-layout { + grid-column: 1; + } .main-content { padding-top: 24px; - padding-bottom: calc(100px + env(safe-area-inset-bottom)); + padding-bottom: 32px; } .inline-expansion { scroll-margin-top: 24px; diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index 4110f55..d1f92fc 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -1267,10 +1267,14 @@ for (const viewport of [ await play.scrollIntoViewIfNeeded(); // Settle any preceding smooth reveal before recording the user's position. await page.evaluate(() => - window.scrollBy({ top: 0, behavior: "instant" }), + document + .getElementById("app-scroll")! + .scrollBy({ top: 0, behavior: "instant" }), ); const originURL = page.url(); - const originScroll = await page.evaluate(() => window.scrollY); + const originScroll = await page.evaluate( + () => document.getElementById("app-scroll")!.scrollTop, + ); expect(originScroll).toBeGreaterThan(viewport.height); if (exit === "Stop") await page.screenshot({ @@ -1306,7 +1310,9 @@ for (const viewport of [ await expect .poll(async () => Math.abs( - (await page.evaluate(() => window.scrollY)) - originScroll, + (await page.evaluate( + () => document.getElementById("app-scroll")!.scrollTop, + )) - originScroll, ), ) .toBeLessThanOrEqual(2); @@ -2697,7 +2703,11 @@ test("Settings section navigation preserves drafts on desktop and mobile", async ), ).toBeInViewport(); } - await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + await page.evaluate(() => + document + .getElementById("app-scroll")! + .scrollTo({ top: 0, behavior: "instant" }), + ); await page.mouse.wheel(0, -1); await expect( navigation.getByRole("link", { name: "Provider", exact: true }), @@ -4658,7 +4668,11 @@ test("hero crossfades without repeating or jumping on refresh and pauses during await expect(heading).not.toHaveText(current); current = (await heading.textContent())!; await page.setViewportSize({ width, height: 350 }); - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.evaluate(() => + document + .getElementById("app-scroll")! + .scrollTo(0, document.getElementById("app-scroll")!.scrollHeight), + ); await expect(hero).not.toBeInViewport(); await tick(); await expect(heading).toHaveText(current); @@ -5822,7 +5836,6 @@ test.describe("Chrome iPad viewport recovery", () => { const playerURL = page.url(); const viewport = page.locator('meta[name="viewport"]'); const original = await viewport.getAttribute("content"); - await page.clock.install(); const reportWindowWidth = async (width: number) => { // Reproduce the measured Chrome mismatch: its outer window is landscape, @@ -5835,6 +5848,13 @@ test.describe("Chrome iPad viewport recovery", () => { }); window.dispatchEvent(new Event("resize")); }, width); + // Allow the browser to render the viewport change before interacting. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); }; // Desktop WebKit's mobile emulation does not provide iPad's element // fullscreen host. Exercise native-exit handling there; Chromium also uses @@ -5843,7 +5863,10 @@ test.describe("Chrome iPad viewport recovery", () => { browserName === "chromium" ? ["element", "native"] : ["native"]; for (const fullscreen of fullscreenModes) { const beforeFullscreen = await viewport.getAttribute("content"); + // Desktop Chromium cannot resize its fullscreen host. Set up the measured + // stale viewport before entry; rotate the reported outer window in fullscreen. await page.setViewportSize({ width: 820, height: 682 }); + await expect(page.locator(".app-shell")).toHaveCSS("height", "682px"); if (fullscreen === "element") { await page .getByRole("button", { name: "Full screen", exact: true }) @@ -5861,7 +5884,6 @@ test.describe("Chrome iPad viewport recovery", () => { }); } await reportWindowWidth(1180); - await page.clock.fastForward(500); await expect(viewport).toHaveAttribute("content", beforeFullscreen!); if (fullscreen === "element") @@ -5876,7 +5898,6 @@ test.describe("Chrome iPad viewport recovery", () => { }); element.dispatchEvent(new Event("webkitendfullscreen")); }); - await page.clock.fastForward(500); await expect( page.getByRole("navigation", { name: "Library", exact: true }), ).toBeVisible(); @@ -5890,28 +5911,73 @@ test.describe("Chrome iPad viewport recovery", () => { exact: true, }); const expectReachableSidebar = async () => { - const visibleHeight = await page.evaluate( - () => window.visualViewport!.height, - ); - for (const name of ["Search", "Home", "VOD", "Live TV", "Account"]) { - const button = navigation.getByRole("button", { name, exact: true }); - const box = (await button.boundingBox())!; - expect(box.y, `${name} is below the top edge`).toBeGreaterThanOrEqual( - 0, - ); - expect( - box.y + box.height, - `${name} fits the visible window without scrolling the page`, - ).toBeLessThanOrEqual(visibleHeight); - } + await expect(async () => { + // Read viewport and element coordinates together: mobile browser + // panning can move the visible origin without scrolling the document. + const { top, bottom, buttons } = await page + .locator(".app-sidebar button[aria-label]") + .evaluateAll((elements) => ({ + top: visualViewport!.offsetTop, + bottom: visualViewport!.offsetTop + visualViewport!.height, + buttons: elements.map((element) => ({ + name: element.getAttribute("aria-label"), + top: element.getBoundingClientRect().top, + bottom: element.getBoundingClientRect().bottom, + })), + })); + expect(buttons).toHaveLength(6); + for (const button of buttons) { + expect( + button.top, + `${button.name} clears the visible top`, + ).toBeGreaterThanOrEqual(top); + expect( + button.bottom, + `${button.name} fits the visible window`, + ).toBeLessThanOrEqual(bottom); + } + }).toPass({ timeout: 5000 }); }; await expectReachableSidebar(); - await page.evaluate(() => window.scrollBy(0, 200)); + if (browserName === "chromium") { + await page.mouse.move(500, 500); + await page.mouse.wheel(0, 350); + } else + await page + .locator("#app-scroll") + .evaluate((element) => + element.scrollBy({ top: 350, behavior: "instant" }), + ); await expectReachableSidebar(); - const scrollBeforeMenu = await page.evaluate(() => window.scrollY); + if (fullscreen === "element") { + const touch = await page.context().newCDPSession(page); + await touch.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 500, y: 500 }], + }); + for (const y of [450, 400, 350, 300, 250]) { + await touch.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 500, y }], + }); + } + await touch.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + await touch.detach(); + await expectReachableSidebar(); + } + const scrollBeforeMenu = await page.evaluate( + () => document.getElementById("app-scroll")!.scrollTop, + ); await navigation.getByRole("button", { name: "Account" }).click(); await expect(page.getByRole("menu", { name: "Account" })).toBeVisible(); - expect(await page.evaluate(() => window.scrollY)).toBe(scrollBeforeMenu); + expect( + await page.evaluate( + () => document.getElementById("app-scroll")!.scrollTop, + ), + ).toBe(scrollBeforeMenu); await page.keyboard.press("Escape"); expect(page.url()).toBe(playerURL); expect( @@ -5929,10 +5995,8 @@ test.describe("Chrome iPad viewport recovery", () => { if (fullscreen === "element") { // Browser chrome can change the visible height without changing width. await page.setViewportSize({ width: 820, height: 620 }); - await page.clock.fastForward(500); await expectReachableSidebar(); await page.setViewportSize({ width: 820, height: 682 }); - await page.clock.fastForward(500); await expectReachableSidebar(); // Do not reset pinch zoom when the window changes. Resume width updates // after the user returns to normal zoom. @@ -5943,7 +6007,6 @@ test.describe("Chrome iPad viewport recovery", () => { }), ); await reportWindowWidth(820); - await page.clock.fastForward(500); await expect .poll(() => page.evaluate(() => document.documentElement.clientWidth)) .toBe(1180); @@ -5954,7 +6017,6 @@ test.describe("Chrome iPad viewport recovery", () => { } await page.setViewportSize({ width: 820, height: 1180 }); await reportWindowWidth(820); - await page.clock.fastForward(500); await expect( page.getByRole("navigation", { name: "Primary navigation", From 5c9e5c2cb441dab0778209f6d3b7823fc1a73783 Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Fri, 23 Oct 2026 05:06:52 +0000 Subject: [PATCH 2/3] fix: keep visibility tracking and workflow checks tied to the app scroller --- web/src/components/discovery-hero.tsx | 6 ++++-- web/tests/workflow.spec.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/web/src/components/discovery-hero.tsx b/web/src/components/discovery-hero.tsx index 3f0bfcf..6c3f29c 100644 --- a/web/src/components/discovery-hero.tsx +++ b/web/src/components/discovery-hero.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { DiscoveryTitle, Item } from "../api"; +import { appScroller } from "../lib/app-scroll"; import { isContinueWatching, type WatchHistoryEntry } from "../watch-history"; import { PlayButton } from "./ui/play-button"; import { Spinner } from "./spectrumui/spinner-dependencies"; @@ -94,8 +95,9 @@ export function DiscoveryHero({ useEffect(() => { const update = () => setVisible(!document.hidden); document.addEventListener("visibilitychange", update); - const observer = new IntersectionObserver(([entry]) => - setInView(entry.isIntersecting), + const observer = new IntersectionObserver( + ([entry]) => setInView(entry.isIntersecting), + { root: appScroller() }, ); if (element.current) observer.observe(element.current); return () => { diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index d1f92fc..bd7e893 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -184,7 +184,7 @@ async function collapseTitle(page: Page) { await selectedTile.click(); await expect( page.getByRole("region", { name: "Expanded title" }), - ).toHaveCount(0); + ).not.toBeVisible(); } } @@ -4598,9 +4598,15 @@ test("hero crossfades without repeating or jumping on refresh and pauses during await expect(hero.locator(".discovery-lead-art")).toHaveCount(2); await expect(heading).not.toHaveText(movie.name); seen.add(await heading.textContent()); - await tick(true); - seen.add(await heading.textContent()); - expect(seen.size).toBe(3); + await expect + .poll( + async () => { + seen.add(await heading.textContent()); + return seen.size; + }, + { intervals: [100] }, + ) + .toBe(3); expect((await hero.boundingBox())!.height).toBe(initialHeight); let current = (await heading.textContent())!; await hero.hover(); @@ -4674,6 +4680,7 @@ test("hero crossfades without repeating or jumping on refresh and pauses during .scrollTo(0, document.getElementById("app-scroll")!.scrollHeight), ); await expect(hero).not.toBeInViewport(); + current = (await heading.textContent())!; await tick(); await expect(heading).toHaveText(current); await page.setViewportSize({ width, height: 900 }); From cd51f958105922e33278e8a1c857e466e055a8f2 Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Fri, 23 Oct 2026 05:09:19 +0000 Subject: [PATCH 3/3] test: leave teardown to isolated browser fixtures and sample settled interactions --- web/tests/workflow.spec.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index bd7e893..9d0eeeb 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -184,7 +184,7 @@ async function collapseTitle(page: Page) { await selectedTile.click(); await expect( page.getByRole("region", { name: "Expanded title" }), - ).not.toBeVisible(); + ).toHaveCount(0); } } @@ -213,10 +213,6 @@ async function navigatePlayer( ); } -test.afterEach(async ({ page }) => { - await collapseTitle(page); -}); - async function mockApi( page: Page, options: { @@ -4608,20 +4604,21 @@ test("hero crossfades without repeating or jumping on refresh and pauses during ) .toBe(3); expect((await hero.boundingBox())!.height).toBe(initialHeight); - let current = (await heading.textContent())!; await hero.hover(); + let current = (await heading.textContent())!; await tick(); await expect(heading).toHaveText(current); await page.mouse.move(0, 0); await hero.getByRole("button", { name: /^Play / }).focus(); + current = (await heading.textContent())!; await tick(); await expect(heading).toHaveText(current); await page.getByRole("button", { name: "Home", exact: true }).focus(); await page.mouse.move(0, 0); await tick(true); await expect(heading).not.toHaveText(current); - current = (await heading.textContent())!; await hero.hover(); + current = (await heading.textContent())!; const response = page.waitForResponse("**/api/discovery"); await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await response; @@ -4661,6 +4658,7 @@ test("hero crossfades without repeating or jumping on refresh and pauses during }); document.dispatchEvent(new Event("visibilitychange")); }); + current = (await heading.textContent())!; await tick(); await expect(heading).toHaveText(current); await page.evaluate(() => {