import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; // Mocks the API via cy.intercept — this job doesn't run a live backend (see // .github/workflows/ci.yml); apps/api's own Mocha suite covers real API // behavior against a real database (see test/sources.test.ts). // // "the sources reference list has options"/"the household's enabled // sources are empty" resolve from cypress/support/step_definitions/ (the // preprocessor's step-lookup is *not* global across cypress/e2e/ — only a // feature's own same-named file/directory plus that shared folder are // searched, see its error message when a step isn't found). recipes.ts // sits directly in cypress/e2e/ (not that shared folder), so its own // "the disliked ingredients list is empty"/"the recipe catalog // contains"/"recipe 2's detail is available" are scoped to recipes.feature // only — this file redeclares its own minimal equivalents rather than // relocating shared infra, the same "each spec's own self-contained // fixtures" precedent recipes.cy.ts already sets alongside recipes.ts. Given("the disliked ingredients list is empty", () => { cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); }); Given("the recipe catalog contains nothing", () => { cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); }); Given("recipe 2's detail is available", () => { cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: { id: 2, name: "Omelette", description: null, picture: null, portions: 2, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [], isFavorite: false, ingredients: [], steps: [ { id: 1, description: "Cuire à la poêle.", picture: null, order: 1, techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], }, ], }, }); }); Given("the household has enabled TheMealDB", () => { cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] }); }); Given("the household has enabled TheMealDB and Marmiton", () => { cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1, 2] }); }); // Issue #133 — the search field is shared across every source tab. Both // sources answer `browse` the same way and each request is aliased so a // scenario can assert which `query` reached which source. Given("browsing any enabled source echoes back its query", () => { const reply = (req: { url: string; reply: (r: unknown) => void }) => { const query = new URL(req.url).searchParams.get("query") ?? ""; req.reply({ statusCode: 200, body: { items: [ { externalId: `${query || "all"}-1`, title: query ? `Résultat « ${query} »` : "Tout le catalogue", picture: null, url: "https://example.test/1", alreadyImported: false, recipeId: null, }, ], nextCursor: null, }, }); }; cy.intercept("GET", "**/sources/theMealDb/browse*", reply).as("browseTheMealDb"); cy.intercept("GET", "**/sources/marmiton/browse*", reply).as("browseMarmiton"); }); When("I type {string} into the recipe search field", (text: string) => { cy.get(".recipes-page__search").clear().type(text); }); Then("the recipe search field should contain {string}", (text: string) => { cy.get(".recipes-page__search").should("have.value", text); }); Then( "{string} should have been browsed with the query {string}", (source: string, query: string) => { const alias = source === "Marmiton" ? "@browseMarmiton.all" : "@browseTheMealDb.all"; // `.all` + a retried `.should` — an empty-query browse fires first (tab // opened before anything was typed) and React StrictMode double-invokes // the fetch effect in the dev server, so assert "one of the requests // carried the term" rather than waiting on a specific occurrence. cy.get(alias).should((calls: { request: { url: string } }[]) => { const hit = calls.some((c) => c.request.url.includes(`query=${encodeURIComponent(query)}`)); expect(hit, `a ${source} browse used query="${query}"`).to.equal(true); }); }, ); Given("browsing TheMealDB returns some items", () => { cy.intercept("GET", "**/sources/theMealDb/browse*", { statusCode: 200, body: { items: [ { externalId: "52795", title: "Chicken Handi", picture: null, url: "https://www.themealdb.com/meal/52795", alreadyImported: true, recipeId: 2, }, { externalId: "9999", title: "Fish Pie", picture: null, url: "https://www.themealdb.com/meal/9999", alreadyImported: false, recipeId: null, }, ], nextCursor: null, }, }); }); /** * A second, distinct item from `Given("browsing TheMealDB returns some * items")`'s page-1 pair — used by the infinite-scroll/retry scenarios * below, which need to tell "the item that only shows up once the *next* * page has loaded" apart from what's already visible on page 1. */ const BEEF_WELLINGTON_ITEM = { externalId: "77123", title: "Beef Wellington", picture: null, url: "https://www.themealdb.com/meal/77123", alreadyImported: false, recipeId: null, }; const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = { items: [ { externalId: "52795", title: "Chicken Handi", picture: null, url: "https://www.themealdb.com/meal/52795", alreadyImported: true, recipeId: 2, }, { externalId: "9999", title: "Fish Pie", picture: null, url: "https://www.themealdb.com/meal/9999", alreadyImported: false, recipeId: null, }, ], nextCursor: "2", }; Given("browsing TheMealDB returns two pages of items", () => { cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { const isNextPage = req.url.includes("cursor="); req.reply({ statusCode: 200, body: isNextPage ? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } : CHICKEN_HANDI_AND_FISH_PIE_PAGE_1, }); }); }); /** * 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 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") ?? ""; threePageCursorsRequested.push(cursor); req.reply({ statusCode: 200, body: pages[cursor] ?? { items: [], nextCursor: null } }); }); }); When("I scroll the source list to the bottom {int} times", (times: number) => { for (let attempt = 0; attempt < times; attempt += 1) { cy.get(".source-items-column .recipe-table-wrap").scrollTo("bottom", { ensureScrollable: false, }); // IntersectionObserver callbacks land on their own task, and the next // page's fetch + merge then needs a render — give both a beat before // the next scroll (which needs fresh content below it to have anything // new to reveal). cy.wait(350); } }); Then("the third page's items should have loaded", () => { // Page 3's marker sits ~30 rows down inside the list's own overflow // container — scroll it in before asserting, like `common.steps.ts`'s // ingredient-row check does. With the stale-closure loop it never loads // at all: `handleLoadMore` keeps re-requesting cursor-2. cy.contains(".recipe-table__name", "Page 3 Item 1").scrollIntoView().should("be.visible"); }); Then("no browse cursor should have been requested more than twice", () => { const counts = new Map(); for (const cursor of threePageCursorsRequested) { counts.set(cursor, (counts.get(cursor) ?? 0) + 1); } // React StrictMode double-invokes effects in the e2e dev server (see // recipes.cy.ts), so the panel's initial fetch + one-page-ahead prefetch // can each fire twice — hence "twice", not "once". The issue #38 loop // blows well past that: every scroll re-requests the same stale cursor. const offenders = [...counts.entries()].filter(([, n]) => n > 2); expect(offenders, `cursor request counts: ${JSON.stringify([...counts])}`).to.have.length(0); }); // The panel prefetches the next page as soon as page 1 is on screen (before // anyone's actually waited on it), so the *first* request for it is that // prefetch — this is what actually fails "once", not a request triggered by // a click. `handleLoadMore`'s own retry then makes a genuinely fresh // request (see its own doc comment on why a failed prefetch gets cleared), // which is the one that succeeds here. Given("browsing TheMealDB's next page fails once, then succeeds", () => { let nextPageAttempts = 0; cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { if (!req.url.includes("cursor=")) { req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 }); return; } nextPageAttempts += 1; if (nextPageAttempts === 1) { req.reply({ statusCode: 500, body: {} }); } else { req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } }); } }); }); Then("I should see a message to retry loading more", () => { cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible"); }); Given("previewing TheMealDB item {string} is available", (externalId: string) => { cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, { statusCode: 200, body: { sourceKey: "theMealDb", externalId, name: "Fish Pie", description: null, picture: null, portions: 4, sourceUrl: "https://www.themealdb.com/meal/9999", ingredients: [ { rawText: "1 onion", quantity: 1, ingredient: { id: 1, key: "onion", icon: "VEGETABLE", category: "freshProduce", subcategory: "vegetables", reproducible: false, allergens: [], diets: [], }, unit: null, }, { rawText: "some mystery paste", quantity: null, ingredient: null, unit: null }, ], steps: [ { description: "Cuire à la poêle.", picture: null, techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], }, ], }, }); }); Then("I should see the source item {string}", (title: string) => { cy.contains(".recipe-table__name", title).should("be.visible"); }); When("I click the source item {string}", (title: string) => { cy.contains(".recipe-table__name", title).click(); }); Then("the source item {string} should be marked as already imported", (title: string) => { cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible"); }); // Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s // own scenarios in planning.ts) never imports anything — the only action // this preview offers is a discreet way out to the item's own page. Then("I should see a discreet link to the item's original page", () => { cy.get(".recipe-detail-panel__source-link").should("be.visible"); });