Le scénario échouait en CI sur deux points de fragilité, pas sur le fond :
1. `IntersectionObserver` ne se redéclenche qu'à une *transition*
d'intersection — avec des pages courtes la sentinelle restait visible en
continu et seule la 2e page se chargeait. Les pages font maintenant 15
éléments (la liste déborde son conteneur) et chaque page suivante est
tirée par un `scrollTo("bottom")` explicite, répété 5 fois.
2. Une fois la liste débordante, `should("be.visible")` sur un élément
sorti du conteneur `overflow:auto` échoue (même classe de bug que #1) —
l'assertion "3e page chargée" fait maintenant `.scrollIntoView()` avant.
L'assertion anti-boucle porte sur le journal des curseurs demandés
(tolérance 2× pour le double-effet React StrictMode du serveur e2e), pas
sur un `Set.size` strict.
Vérifié localement : 7/7 avec le correctif, "Page 3 Item 1" jamais chargée
sans lui.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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("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<string, ReturnType<typeof threePagePage>> = {
|
|
"": 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<string, number>();
|
|
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");
|
|
});
|