All checks were successful
Avant : chaque onglet de source rendait son propre champ recherche, avec
son propre state, et `RecipeSourcesPanel` est remonté (`key={sourceKey}`)
à chaque changement de source — la requête tapée était donc perdue en
passant d'une source à une autre.
Maintenant : un seul champ, dans l'en-tête de `RecipesPage`. `search` /
`debouncedSearch` (déjà présents pour les onglets réels) sont rendus en
permanence et `debouncedSearch` est passé en prop à `RecipeSourcesPanel`,
qui n'a plus ni `useState` de recherche, ni `<input>`, ni effet de
debounce internes — son effet de `browse` dépend de la prop. La requête
survit donc au changement de source (le `key` remonte toujours la liste +
le préchargement, mais le terme vit au-dessus), et elle est commune aux
onglets réels comme aux sources.
`RecipePickerDialog` : même chose — le champ recherche sort du bloc de
filtres `activeSourceKey === null` (les filtres ingrédients/régimes/foyer
y restent, ils ne concernent que les onglets en base) et `debouncedSearch`
est passé au panneau.
- SCSS : `.recipes-page__header--sources` supprimé (le panneau n'a plus
d'en-tête). Clé i18n morte `recipes.sources.searchPlaceholder` retirée.
- `specs/frontend-architecture.md` : recherche au niveau page.
Tests : `recipe-sources.feature` — nouveau scénario « la requête est
conservée en changeant d'onglet de source » (tape « poulet » sur TheMealDB,
bascule sur Marmiton, le champ garde « poulet » et Marmiton est interrogé
avec `query=poulet`). `pnpm lint` / `pnpm build` verts ; Cypress local :
recipe-sources 8/8, recipes 5/5, recipes.cy 12/12, planning 3/3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
348 lines
12 KiB
TypeScript
348 lines
12 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("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<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");
|
|
});
|