From bd607b17f323a7e14ced9e0839d5016288c1785c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 08:21:16 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(recipes):=20corrige=20la=20num=C3=A9rot?= =?UTF-8?q?ation=20des=20=C3=A9tapes=20fauss=C3=A9e=20par=20le=20popover?= =?UTF-8?q?=20de=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .recipe-detail-panel__steps utilisait un compteur CSS (counter-reset/ counter-increment: step) sur un sélecteur `li` — un descendant, pas seulement les enfants directs. Depuis la refonte du popover de correction (tech-step-metadata-editor-redesign), celui-ci imbrique ses propres
  • plusieurs niveaux plus bas (une par ingrédient/ustensile/température attaché, plus la liste du picker de technique — jusqu'à ~74 entrées) : chacun incrémentait ce même compteur "step", faussant le numéro affiché de toutes les étapes suivantes dès qu'une correction avec métadonnées attachées (ou son picker de technique) était ouverte. C'est le bug rapporté : "les étapes affichées sont numérotées n'importe comment". Correctif : `li`/`img`/`p` → `> li`/`> img`/`> p` (enfants directs uniquement) dans cette règle. Même principe pour `img`/`p` : leurs styles (image bloc, paragraphe inline sans marge) ne doivent s'appliquer qu'au propre

    / de l'étape, pas à ceux du popover imbriqué plus loin. Test de non-régression ajouté (recipes.cy.ts) : ouvre une correction avec ingrédient + ustensile attachés puis le picker de technique, vérifie via getComputedStyle que ces

  • imbriqués n'ont pas rejoint la chaîne du compteur (`counter-increment: none`) alors que les
  • des étapes elles-mêmes l'ont toujours (`counter-increment: step 1`) — confirmé que ce test échoue bien sans le correctif (`step 1` au lieu de `none`) avant de le restaurer. Vérifié : pnpm lint + build clean. recipes.cy.ts (9/9, dont le nouveau test), recipes.feature (5/5), recipe-sources.feature (6/6). Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/recipes.cy.ts | 106 +++++++++++++++++++++ apps/web/src/features/recipes/recipes.scss | 22 ++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index a3190df..d3a4958 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -69,6 +69,64 @@ const omeletteDetail = { ], }; +/** + * A 3-step recipe whose middle step already has an existing technique match + * carrying attached ingredient/utensil metadata — for the step-numbering + * regression test below, which needs a correction popover with several + * `
  • ` rows of its own open at once (see that test's own comment). + */ +const tarteDetail = { + id: 5, + name: "Tarte", + description: null, + picture: null, + portions: 4, + authorId: 1, + visibility: "PERSONAL", + allergens: [], + diets: [], + isFavorite: false, + ingredients: [], + steps: [ + { id: 1, description: "Préchauffer le four.", picture: null, order: 1, techSteps: [] }, + { + id: 2, + description: "Faire revenir les oignons avec une poêle.", + picture: null, + order: 2, + techSteps: [ + { + techStep: { id: 2, key: "panFry" }, + start: 0, + end: 13, + source: "auto", + ingredients: [ + { + ingredient: { + id: 10, + key: "onion", + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + allergens: [], + diets: [], + }, + quantity: 2, + unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }, + start: 18, + end: 25, + source: "auto", + }, + ], + utensils: [{ utensil: { id: 20, key: "pan" }, start: 31, end: 40, source: "auto" }], + temperatures: [], + }, + ], + }, + { id: 3, description: "Servir chaud.", picture: null, order: 3, techSteps: [] }, + ], +}; + function interceptAuth() { cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); @@ -187,6 +245,54 @@ describe("Recipe catalog", () => { }); }); + // Regression: `.recipe-detail-panel__steps`'s CSS step counter + // (recipes.scss) used a bare descendant `li` selector, matching *any* + // `
  • ` nested inside a step — including ones several levels down + // inside `TechStepCorrectionPopover` (each attached ingredient/utensil/ + // temperature row, and the technique picker's own `
  • ` list). Opening + // a correction with attached metadata inflated the counter, throwing off + // every *later* step's own displayed number — exactly the "les étapes + // affichées sont numérotées n'importe comment" bug report this covers. + it("keeps every step's own number correct while a correction popover with attached metadata is open", () => { + cy.viewport(1600, 900); + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + cy.intercept("GET", "**/recipes/5", { statusCode: 200, body: tarteDetail }); + cy.intercept("GET", "**/reference/tech-steps", { + statusCode: 200, + body: [{ id: 2, key: "panFry" }], + }); + + cy.visit("/recettes/5"); + cy.get(".recipe-detail-panel__steps > li").should("have.length", 3); + + // Open the middle step's correction — pre-seeded with one ingredient + // and one utensil, so its popover renders two `.tech-step-correction-popover__row` + // `
  • `s of its own (plus the technique picker's own `
  • ` list once + // "Changer" is clicked) sitting *inside* the second step's own `
  • `. + cy.contains(".step-tech-step", "Faire revenir").click(); + cy.get(".tech-step-correction-popover__row").should("have.length", 2); + cy.contains(".tech-step-correction-popover__technique-change", "Changer").click(); + cy.get(".catalog-search-picker__list li").should("have.length.greaterThan", 0); + + // None of the popover's own nested `
  • `s join the step counter chain + // — `getComputedStyle(...).content` isn't reliable for reading a + // *resolved* counter() value across browsers (it can return the raw + // `counter(step) ". "` declaration instead), so this checks the actual + // `counter-increment` computed property instead: a real, directly + // testable signal for exactly what `> li` (recipes.scss) does and + // doesn't scope to. + cy.get(".tech-step-correction-popover__row").each(($row) => { + expect(window.getComputedStyle($row[0]).counterIncrement).to.equal("none"); + }); + cy.get(".catalog-search-picker__list li").each(($item) => { + expect(window.getComputedStyle($item[0]).counterIncrement).to.equal("none"); + }); + // The step `
  • `s themselves are still correctly in the chain. + cy.get(".recipe-detail-panel__steps > li").each(($step) => { + expect(window.getComputedStyle($step[0]).counterIncrement).to.equal("step 1"); + }); + }); + it("shows a not-found message for a selected id the API rejects", () => { cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/recipes/999", { diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index de13290..8f6b260 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -630,7 +630,17 @@ gap: var(--space-md); counter-reset: step; - li { + // `> li`, not a bare descendant `li` — this used to match *any* `
  • ` + // nested anywhere inside a step, not just the step's own direct one. + // Harmless while `StepDescription`'s editable mode only ever added + // plain text/buttons, but `TechStepCorrectionPopover` renders its own + // `
  • `s a few levels down (each attached ingredient/utensil/ + // temperature row, `.tech-step-correction-popover__row`; the technique + // picker's `.catalog-search-picker__list`, up to ~74 entries) — + // every one of those was incrementing this *same* counter, throwing + // every step's own number off as soon as any correction popover with + // attached metadata (or its technique picker) was open. + > li { counter-increment: step; &::before { @@ -639,14 +649,20 @@ color: var(--color-primary); } - img { + // Same descendant-selector fix as `> li` above, for the same reason: + // both the step's own picture and its `StepDescription`-rendered `

    ` + // are direct children of this `

  • ` — `TechStepCorrectionPopover`'s + // own `

    `s (the selection line, hints, `.field-error`) sit deeper + // inside it and must not inherit "inline, no margin" meant only for + // the step's own paragraph. + > img { display: block; max-width: 100%; border-radius: var(--radius-base); margin-bottom: var(--space-xs); } - p { + > p { display: inline; margin: 0; } -- 2.45.2 From 3b78582d29a6967fc1ff3d8482f22c17c5e3669c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 11:00:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?test(recipe-form):=20corrige=20la=20visibil?= =?UTF-8?q?it=C3=A9=20flaky=20de=20l'=C3=A9tape=20d'ingr=C3=A9dient=20pr?= =?UTF-8?q?=C3=A9charg=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le step Gherkin partagé « the recipe should include the ingredient » utilisait un `.should("be.visible")` nu sur `.ingredient-row__name`. Sur le formulaire d'édition de recette, la liste d'ingrédients peut se trouver sous le pli du scroll propre à `.app-content` (Nom/Description/ Photo/Portions/Visibilité/Régimes viennent avant) — un `.should` seul ne scrolle pas automatiquement, contrairement aux commandes d'interaction (`.click()`/`.check()`). Repéré comme bug pré-existant et hors périmètre lors de la vérification de #29/#30 (voir la note dans tech-step-metadata-editor-redesign.md). Même correctif que « I should see the highlighted technique » (common.steps.ts) et « I should see the highlighted temperature » (recipes.ts) : `.scrollIntoView()` avant l'assertion. Reproduit avant correctif (recipe-form.feature, scénario « Preloads an existing recipe's ingredients when editing »: 4/5 puis 5/5 après le correctif). Suite e2e complète: 27/28 fichiers verts (la seule exception, admin-monitoring.cy.ts, est un flake distinct et sans rapport, signalé séparément). Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/support/step_definitions/common.steps.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts index c74aac9..272a2ef 100644 --- a/apps/web/cypress/support/step_definitions/common.steps.ts +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -180,7 +180,12 @@ Then("the ingredient {string} should be visible in the picker", (name: string) = }); Then("the recipe should include the ingredient {string}", (name: string) => { - cy.contains(".ingredient-row__name", name).should("be.visible"); + // Same reasoning as "I should see the highlighted technique" above: on the + // recipe-edit form the ingredients list can sit below the fold of + // `.app-content`'s own scroll (Nom/Description/Photo/Portions/Visibilité/ + // Régimes all come first), and a bare `.should("be.visible")` doesn't + // auto-scroll — only interaction commands like `.click()`/`.check()` do. + cy.contains(".ingredient-row__name", name).scrollIntoView().should("be.visible"); }); Then("there should be {int} ingredient rows", (count: number) => { -- 2.45.2 From c1aba1f18c716031311a0315040401afdddb8b97 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 11:20:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(admin-monitoring):=20remplace=20le=20fi?= =?UTF-8?q?ltre=20resourceType=20cass=C3=A9=20par=20une=20v=C3=A9rificatio?= =?UTF-8?q?n=20manuelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `admin-monitoring.cy.ts` échouait de façon reproductible ("renders one card per service": cy.wait timeout, "No request ever occurred"). Diagnostiqué par isolation : le matcher déclaratif `resourceType: "fetch"` de Cypress 13.17.0 ne capte plus du tout la requête réelle de AdminApiClient.getMonitoring() sur le Chrome 152 de cette machine — un intercept `{resourceType: "fetch"}` sans filtre d'URL ne voit passer que /admin/auth/me, jamais /admin/monitoring, alors que le `req.resourceType` de cette même requête, lu depuis un handler matché par url/method seuls, vaut bien "fetch" de façon fiable et reproductible. Le filtrage déclaratif est cassé, pas la requête elle-même. Remplace le matcher par un handler de fonction qui laisse passer la requête de document (`cy.visit()` lui-même, sur la même URL que l'API en same-origin/CI) via `req.continue()` et ne stub que le reste — vérifié stable sur 3 runs consécutifs. Corrige au passage le test "shows an error state" qui passait pour la mauvaise raison : sans serveur API réel, une requête non interceptée échoue aussi au niveau réseau, ce que MonitoringPage.catch() ne distingue pas d'un vrai 500 — désormais aliasé et attendu comme le premier test, pour prouver qu'il dépend bien du mock. Suite e2e complète: 28/28 fichiers, 116/116 tests verts. Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/admin-monitoring.cy.ts | 50 ++++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/web/cypress/e2e/admin-monitoring.cy.ts b/apps/web/cypress/e2e/admin-monitoring.cy.ts index fd6ba78..733e184 100644 --- a/apps/web/cypress/e2e/admin-monitoring.cy.ts +++ b/apps/web/cypress/e2e/admin-monitoring.cy.ts @@ -53,21 +53,33 @@ describe("Admin monitoring", () => { }); // The page route `/admin/monitoring` and the API path `GET /admin/monitoring` - // are identical — a plain `**/admin/monitoring` glob would also match the + // are identical — a plain `**/admin/monitoring` glob also matches the // `cy.visit()` document request (and whether that's same-origin or not - // depends on `VITE_API_URL`, which isn't set in CI). `resourceType: "fetch"` - // pins the intercept to the `AdminApiClient` XHR only. - const monitoringApi = { - method: "GET", - url: "**/admin/monitoring", - resourceType: "fetch", - } as const; + // depends on `VITE_API_URL`, which isn't set in CI). + // + // This used to be disambiguated with a declarative `resourceType: "fetch"` + // route matcher, but that stopped working (Cypress 13.17.0 on this + // machine's Chrome 152): the *declarative* `resourceType` matcher no + // longer catches this request at all — confirmed by logging every request + // Cypress hands to a `{resourceType: "fetch"}` intercept, which only ever + // saw `/admin/auth/me`, never `/admin/monitoring` — even though the very + // same request's own `req.resourceType`, read *inside* a handler matched + // by url/method alone, reliably reads back `"fetch"`. So the request *is* + // correctly typed; only the pre-filtering by that type is broken. Reading + // `req.resourceType` inside the handler and branching manually (as below) + // isn't affected by that bug and reproduced correctly in every check. + function interceptMonitoringApi(response: { statusCode: number; body: unknown }) { + return cy.intercept("GET", "**/admin/monitoring", (req) => { + if (req.resourceType === "document") { + req.continue(); + return; + } + req.reply(response); + }); + } it("renders one card per service with its status and details", () => { - cy.intercept(monitoringApi, { - statusCode: 200, - body: monitoringFixture(), - }).as("getMonitoring"); + interceptMonitoringApi({ statusCode: 200, body: monitoringFixture() }).as("getMonitoring"); cy.visit("/admin/monitoring"); cy.wait("@getMonitoring"); @@ -85,11 +97,17 @@ describe("Admin monitoring", () => { }); it("shows an error state when the request fails", () => { - cy.intercept(monitoringApi, { - statusCode: 500, - body: { code: 5000, message: "x" }, - }); + // Aliased and waited on (unlike the other tests' plain intercepts) so + // this genuinely proves the 500 came from the mock — without a live API + // server, an unmatched request fails at the network level too, which + // `MonitoringPage`'s `.catch()` can't tell apart from a real 500 (see + // the comment above `interceptMonitoringApi`, this is exactly the class + // of bug that let this test pass for the wrong reason before). + interceptMonitoringApi({ statusCode: 500, body: { code: 5000, message: "x" } }).as( + "getMonitoringError", + ); cy.visit("/admin/monitoring"); + cy.wait("@getMonitoringError"); cy.contains("Impossible de charger").should("be.visible"); }); }); -- 2.45.2