diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature index aee6fb6..0225ac3 100644 --- a/apps/web/cypress/e2e/recipe-sources.feature +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -63,9 +63,11 @@ Feature: Browsing external recipe sources And browsing TheMealDB returns three pages, each addressed by its own cursor When I visit "/recettes" And I click the button "TheMealDB" - Then I should see the source item "Page One Dish" - And I should see the source item "Page Two Dish" - And I should see the source item "Page Three Dish" + Then I should see the source item "Page 1 Item 1" + When I scroll the source list to the bottom + Then I should see the source item "Page 2 Item 1" + When I scroll the source list to the bottom + Then I should see the source item "Page 3 Item 1" And no browse cursor should have been requested more than once Scenario: Offers a retry when loading the next page fails diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts index 47664c1..fea1b6d 100644 --- a/apps/web/cypress/e2e/recipe-sources.ts +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -133,28 +133,39 @@ Given("browsing TheMealDB returns two pages of items", () => { }); /** - * Three real pages, each addressed by a distinct `cursor` value the mock - * actually parses (not just "does the URL contain `cursor=`"). Records - * every cursor it serves so the scenario can assert none was requested - * twice — the tell-tale of the issue #38 loop, where a stale closure - * re-requests the same page forever instead of advancing. + * Three full pages, each addressed by a distinct `cursor` value the mock + * actually parses (not just "does the URL contain `cursor=`"). Pages are + * deliberately large enough to overflow the list's own scroll container so + * the sentinel starts below the fold — each `scrollTo("bottom")` then + * produces a real IntersectionObserver transition, the only thing that + * makes the observer fire again. Records every cursor it serves so the + * scenario can assert none was requested twice — the tell-tale of the + * issue #38 loop, where a stale closure re-requests the same page forever + * instead of advancing. */ +const THREE_PAGE_SIZE = 15; const threePageCursorsRequested: string[] = []; +function threePagePage(pageNo: number, nextCursor: string | null) { + return { + items: Array.from({ length: THREE_PAGE_SIZE }, (_, index) => ({ + externalId: `p${pageNo}-${index}`, + title: `Page ${pageNo} Item ${index + 1}`, + picture: null, + url: `https://www.themealdb.com/meal/p${pageNo}-${index}`, + alreadyImported: false, + recipeId: null, + })), + nextCursor, + }; +} + Given("browsing TheMealDB returns three pages, each addressed by its own cursor", () => { threePageCursorsRequested.length = 0; - const item = (externalId: string, title: string) => ({ - externalId, - title, - picture: null, - url: `https://www.themealdb.com/meal/${externalId}`, - alreadyImported: false, - recipeId: null, - }); - const pages: Record = { - "": { items: [item("p1", "Page One Dish")], nextCursor: "cursor-2" }, - "cursor-2": { items: [item("p2", "Page Two Dish")], nextCursor: "cursor-3" }, - "cursor-3": { items: [item("p3", "Page Three Dish")], nextCursor: null }, + const pages: Record> = { + "": threePagePage(1, "cursor-2"), + "cursor-2": threePagePage(2, "cursor-3"), + "cursor-3": threePagePage(3, null), }; cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { const cursor = new URL(req.url).searchParams.get("cursor") ?? ""; @@ -163,14 +174,18 @@ Given("browsing TheMealDB returns three pages, each addressed by its own cursor" }); }); -Then("no browse cursor should have been requested more than once", () => { - // Let the panel settle (auto-scroll + one-page-ahead prefetch) before - // asserting. A looping IntersectionObserver would re-hit "" or "cursor-2" - // here; the latest-ref fix keeps each cursor to a single request. - cy.wait(600).then(() => { - const seen = [...threePageCursorsRequested]; - expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size); +When("I scroll the source list to the bottom", () => { + cy.get(".source-items-column .recipe-table-wrap").scrollTo("bottom", { + ensureScrollable: false, }); + // IntersectionObserver callbacks are delivered on their own task, and the + // next page's fetch + merge then needs a render — give both a beat. + cy.wait(400); +}); + +Then("no browse cursor should have been requested more than once", () => { + const seen = [...threePageCursorsRequested]; + expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size); }); // The panel prefetches the next page as soon as page 1 is on screen (before