// 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 and recipe catalog 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"); }); it("centers the Liste de courses stub and settings pages, with equal space on both sides", () => { cy.visit("/liste-de-courses"); assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem cy.visit("/parametres/compte"); assertCenteredColumn(".settings-page", 896); // max-width: 56rem }); /** 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); }); }); } /** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */ function assertCenteredColumn(selector: string, maxWidthPx: number) { cy.get(".app-content").then(($content) => { const contentRect = $content[0].getBoundingClientRect(); cy.get(selector).should(($page) => { const pageRect = $page[0].getBoundingClientRect(); expect(pageRect.width).to.be.closeTo(maxWidthPx, 2); const leftGap = pageRect.left - contentRect.left; const rightGap = contentRect.right - pageRect.right; expect(leftGap).to.be.closeTo(rightGap, 2); }); }); } }); describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => { beforeEach(() => { interceptAuth(); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); }); it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => { cy.viewport(375, 812); cy.visit("/"); cy.get(".app-sidebar").should(($sidebar) => { const rect = $sidebar[0].getBoundingClientRect(); expect(rect.width).to.be.closeTo(375, 1); // A short horizontal bar, not the tall vertical rail — well under the // desktop rail's own content-driven height. expect(rect.height).to.be.lessThan(120); }); // Nothing to collapse into on a bar with no rail to shrink. cy.get(".app-sidebar__collapse-toggle").should("not.be.visible"); cy.get(".app-sidebar__version").should("not.be.visible"); // The main nav must stay fully legible and tappable — icon *and* label // — falling back to horizontal scroll instead of ever being crushed // down to unlabeled slivers (see AppLayout.scss's own comment on this // exact failure mode). cy.get(".app-sidebar__nav").should(($nav) => { expect(getComputedStyle($nav[0]).overflowX).to.equal("auto"); }); cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible"); cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40); }); // Contrast: the settings/account toggles' labels *do* collapse to // icon-only here — there's no room for both a full nav row and full // text labels on every piece of chrome at once. cy.contains("button", "Paramètres").find("span.label").should("not.be.visible"); }); }); describe("Color theme — light/dark tokens actually reach the rendered chrome", () => { beforeEach(() => { interceptAuth(); cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); }); it("renders the sidebar surface and the active nav link in the light palette by default", () => { cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }); cy.visit("/"); cy.get("html").should("have.attr", "data-theme", "light"); // --color-surface: #ffffff cy.get(".app-sidebar").should(($el) => { expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(255, 255, 255)"); }); // --color-primary: #2e6b4a, applied as the active nav link's background. cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(46, 107, 74)"); }); }); it("switches every themed color to the dark palette when the user's preference is DARK", () => { cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } }); cy.visit("/"); cy.get("html").should("have.attr", "data-theme", "dark"); // --color-surface: #1c221e cy.get(".app-sidebar").should(($el) => { expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(28, 34, 30)"); }); // --color-primary: #5fae7e cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(95, 174, 126)"); }); // The page background token switches too, not just the sidebar. cy.get("body").should(($body) => { // --color-background: #14181a expect(getComputedStyle($body[0]).backgroundColor).to.equal("rgb(20, 24, 26)"); }); }); });