// 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. // // Pure layout/style specs — plain Cypress, no Cucumber (see the 3-way test // split: journeys via Gherkin, generic components via Component Testing, // and this file's category: the app *shell*'s own structural/visual // contract, independent of any particular page's content or user journey). // Regression coverage for the two bugs fixed in #21: the sidebar scrolling // away with a tall page, and pages not consistently using the available // width (some stuck to the left edge with a lopsided gap, others correctly // full-bleed). const authenticatedProfile = { id: 1, firstName: "Alice", lastName: "Martin", email: "alice@example.com", tokenVersion: 0, houseId: 1, dietId: null, }; function interceptAuth() { cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); } describe("App shell — sidebar is a fixed-width rail on every page", () => { beforeEach(() => { interceptAuth(); }); it("keeps the sidebar at its full expanded width (15rem = 240px), regardless of the page", () => { cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/"); cy.get(".app-sidebar") .should(($sidebar) => { expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); }) // Flush to the top-left corner of the viewport — nothing pushes it // down or in, on any page. .and(($sidebar) => { const rect = $sidebar[0].getBoundingClientRect(); expect(rect.top).to.equal(0); expect(rect.left).to.equal(0); }); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); cy.visit("/recettes"); cy.get(".app-sidebar").should(($sidebar) => { expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); }); }); it("shrinks to the icon-only rail width (4.25rem = 68px) once collapsed, and restores 240px when expanded again", () => { cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/"); cy.get(".app-sidebar").should(($sidebar) => { expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); }); cy.get(".app-sidebar__collapse-toggle").click(); cy.get(".app-sidebar").should(($sidebar) => { expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(68, 1); }); cy.get(".app-sidebar__collapse-toggle").click(); cy.get(".app-sidebar").should(($sidebar) => { expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); }); }); }); describe("App shell — viewport-locked height, independent scroll (#21 regression)", () => { beforeEach(() => { interceptAuth(); }); it("pins the whole shell to exactly the viewport height, never taller", () => { cy.viewport(1200, 700); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/"); cy.get(".app-layout").should(($layout) => { expect($layout[0].getBoundingClientRect().height).to.be.closeTo(700, 1); expect(getComputedStyle($layout[0]).overflow).to.equal("hidden"); }); // The document itself never grows past the viewport — this is the exact // root cause of the original bug (a tall page scrolling the whole // document, dragging the sidebar along with it). cy.document().its("documentElement.scrollHeight").should("be.closeTo", 700, 1); }); it("scrolls only the content area on a page taller than the viewport — the sidebar never moves and the document itself doesn't scroll", () => { cy.viewport(1200, 700); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/"); // A synthetic spacer, far taller than the viewport — deliberately // independent of whatever the planning page's own content happens to // be, since this test is about the shell's scroll contract, not this // particular page's height. cy.get(".app-content").then(($content) => { const spacer = document.createElement("div"); spacer.style.height = "3000px"; spacer.setAttribute("data-cy", "scroll-spacer"); $content[0].appendChild(spacer); }); cy.get(".app-sidebar").then(($sidebar) => { const topBefore = $sidebar[0].getBoundingClientRect().top; cy.get(".app-content").scrollTo("bottom"); cy.get(".app-sidebar").should(($again) => { expect($again[0].getBoundingClientRect().top).to.equal(topBefore); }); }); // The scroll genuinely happened inside `.app-content`... cy.get(".app-content").invoke("scrollTop").should("be.greaterThan", 0); // ...and not on the document/window itself. cy.window().its("scrollY").should("equal", 0); }); }); describe("Page width — full-bleed pages vs. centered reading columns (#21 regression)", () => { beforeEach(() => { interceptAuth(); cy.viewport(1600, 900); }); it("stretches the planning page, recipe catalog, and settings pages across the full content width", () => { cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/"); assertFillsContentWidth(".planning-page"); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); cy.visit("/recettes"); assertFillsContentWidth(".recipes-page"); // Settings pages (Compte/Préférences/Foyer) used to be capped at // max-width: 56rem, cramped in a centered column on wide viewports — // that cap was dropped so they fill the width like every other page. cy.visit("/parametres/compte"); assertFillsContentWidth(".settings-page"); cy.intercept("GET", /\/shopping-list\?/, { statusCode: 200, body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] }, }); cy.visit("/liste-de-courses"); assertFillsContentWidth(".shopping-list-page"); }); /** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */ function assertFillsContentWidth(selector: string) { cy.get(".app-content").then(($content) => { const style = getComputedStyle($content[0]); const available = $content[0].getBoundingClientRect().width - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight); cy.get(selector).should(($page) => { expect($page[0].getBoundingClientRect().width).to.be.closeTo(available, 3); }); }); } }); describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 640px", () => { // Regression coverage for the mobile/tablet UI chantier's foundation // ticket: below this point the desktop `