diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2a7d18..f17379c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,3 +104,8 @@ jobs: # explicitly so `cypress run` finds it. - run: pnpm --filter web exec cypress install - run: pnpm --filter web e2e + # No dev server needed here — Cypress spins up its own Vite dev + # server internally for component testing (see cypress.config.ts's + # `component.devServer`), unlike `e2e` above which needs the real app + # running first. + - run: pnpm --filter web cy:run:component diff --git a/apps/web/cypress.config.ts b/apps/web/cypress.config.ts index 20fd5f4..a05626b 100644 --- a/apps/web/cypress.config.ts +++ b/apps/web/cypress.config.ts @@ -1,26 +1,32 @@ import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor"; import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild"; import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; +import { devServer } from "@cypress/vite-dev-server"; import { defineConfig } from "cypress"; +import viteConfig from "./vite.config"; + +// Disable GPU for headless/sandboxed environments (e.g. CI containers) where +// no GPU device is available — needed by both e2e and component testing, +// each of which has its own independent `setupNodeEvents`. +function disableGpu(on: Cypress.PluginEvents) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium") { + launchOptions.args.push("--disable-gpu", "--no-sandbox"); + } + return launchOptions; + }); +} export default defineConfig({ e2e: { baseUrl: "http://localhost:5173", - // Experimental — testing whether real Gherkin .feature files work with - // this preprocessor version (see experiment/cucumber-cypress). Only one - // throwaway feature exists right now (login.feature); the rest of the - // suite is still plain .cy.ts, matched by the default `**/*.cy.ts` - // pattern alongside `**/*.feature`. + // User journeys, Gherkin-driven (see docs/testing.md once written) live + // alongside plain layout-focused `.cy.ts` specs here — both matched by + // this pattern. Generic component tests are separate, see `component` + // below. specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"], async setupNodeEvents(on, config) { - // Disable GPU for headless/sandboxed environments (e.g. CI containers) - // where no GPU device is available. - on("before:browser:launch", (browser, launchOptions) => { - if (browser.family === "chromium") { - launchOptions.args.push("--disable-gpu", "--no-sandbox"); - } - return launchOptions; - }); + disableGpu(on); await addCucumberPreprocessorPlugin(on, config); on( @@ -33,4 +39,18 @@ export default defineConfig({ return config; }, }, + + // Mounts one generic UI component at a time (components/ui/*), no + // router/backend involved — separate from the e2e suite above, which + // always exercises a full routed page. Reuses the app's own vite.config.ts + // (same React plugin, same Sass setup) rather than duplicating it. + component: { + specPattern: "cypress/component/**/*.cy.tsx", + devServer(devServerConfig) { + return devServer({ ...devServerConfig, viteConfig }); + }, + setupNodeEvents(on) { + disableGpu(on); + }, + }, }); diff --git a/apps/web/cypress/component/CheckboxOption.cy.tsx b/apps/web/cypress/component/CheckboxOption.cy.tsx new file mode 100644 index 0000000..494448b --- /dev/null +++ b/apps/web/cypress/component/CheckboxOption.cy.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { CheckboxOption } from "../../src/components/ui/Checkbox"; + +// First real component test — mounts CheckboxOption in isolation (no +// router, no backend), unlike everything under cypress/e2e/ which always +// visits a full routed page. See cypress.config.ts's `component` block. + +describe("CheckboxOption", () => { + it("renders its label content", () => { + cy.mount( + {}}> + Végétarien + , + ); + + cy.contains("label", "Végétarien").should("be.visible"); + }); + + it("reflects the checked prop on the native input, and the is-selected class", () => { + cy.mount( + {}}> + Végétarien + , + ); + cy.get("input[type=checkbox]").should("not.be.checked"); + cy.get("label").should("not.have.class", "is-selected"); + + cy.mount( + {}}> + Végétarien + , + ); + cy.get("input[type=checkbox]").should("be.checked"); + cy.get("label").should("have.class", "is-selected"); + }); + + it("calls onChange with the toggled value when clicked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.get("input[type=checkbox]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", true); + }); + + it("is a controlled component — stays checked only while the parent says so", () => { + // A tiny stateful wrapper, since CheckboxOption itself takes no + // internal state — this is what actually exercises the checked/onChange + // contract the way a real caller (AllergySelect, the theme picker…) + // would. + function Wrapper() { + const [checked, setChecked] = useState(false); + return ( + + Végétarien + + ); + } + cy.mount(); + + cy.get("input[type=checkbox]").should("not.be.checked").click(); + cy.get("input[type=checkbox]").should("be.checked"); + }); + + it("calls onChange with false when clicking while already checked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.get("input[type=checkbox]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", false); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label") + .should("have.class", "allergy-select__option") + .and("not.have.class", "is-selected"); + + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label").should("have.class", "allergy-select__option").and("have.class", "is-selected"); + }); + + it("has no className at all when the caller doesn't pass one", () => { + cy.mount( + {}}> + Végétarien + , + ); + // `[className, checked && "is-selected"].filter(Boolean).join(" ")` with + // both falsy collapses to "" — worth pinning down since a stray + // "false"/"null" string in `class` would be a real (if harmless-looking) + // regression. + cy.get("label").should("have.attr", "class", ""); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + // The label wraps the input (native HTML forwards the click), which is + // what actually makes the whole row clickable — not just a tiny + // checkbox hitbox. This is real browser behavior, not something the + // component's own code implements, but it's exactly the contract the + // "selectable card" look (global.scss) relies on, so it's worth pinning + // down here rather than trusting it silently. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.contains("label", "Végétarien").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", true); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the checkbox itself", () => { + cy.mount( + {}}> + Végétarien + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); +}); diff --git a/apps/web/cypress/component/RadioOption.cy.tsx b/apps/web/cypress/component/RadioOption.cy.tsx new file mode 100644 index 0000000..d80b6ce --- /dev/null +++ b/apps/web/cypress/component/RadioOption.cy.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { RadioOption } from "../../src/components/ui/Radio"; + +// The `type="radio"` sibling of CheckboxOption.cy.tsx — same "selectable +// card" markup, but exercised for what actually differs about a radio +// input: the mandatory `name`/`value` pair and the mutually-exclusive +// group behavior that's the whole reason to reach for radio over checkbox. + +describe("RadioOption", () => { + it("renders its label content", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.contains("label", "Sombre").should("be.visible"); + }); + + it("sets the native input's name and value", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.get("input[type=radio]") + .should("have.attr", "name", "theme") + .and("have.attr", "value", "dark"); + }); + + it("reflects the checked prop on the native input, and the is-selected class", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("not.be.checked"); + cy.get("label").should("not.have.class", "is-selected"); + + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("be.checked"); + cy.get("label").should("have.class", "is-selected"); + }); + + it("calls onChange with its own value when clicked while unchecked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("does not fire onChange again when clicking a radio that's already checked", () => { + // Native radio inputs only emit a `change` event when their checked + // state actually flips — clicking an already-selected option in a + // group is a no-op, unlike a checkbox which always toggles. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("not.have.been.called"); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.contains("label", "Sombre").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} + className="theme-select__option" + > + Sombre + , + ); + cy.get("label").should("have.class", "theme-select__option").and("have.class", "is-selected"); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the radio itself", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); + + it("behaves as a mutually-exclusive group when several options share the same name", () => { + // A stateful wrapper mirroring UserPreferencesPage's theme picker — the + // one real caller — mounting 3 RadioOptions that share `name="theme"` + // and one `value` of state between them. + function ThemeGroup() { + const [theme, setTheme] = useState<"system" | "light" | "dark">("system"); + return ( + <> + + Système + + + Clair + + + Sombre + + + ); + } + cy.mount(); + + cy.contains("label", "Système").should("have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + cy.contains("label", "Sombre").should("not.have.class", "is-selected"); + + cy.contains("label", "Sombre").click(); + + cy.contains("label", "Sombre").should("have.class", "is-selected"); + cy.contains("label", "Système").should("not.have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + // The native `name` grouping also keeps the browser's own radio + // semantics honest — only one input in the group can be `:checked`. + cy.get("input[type=radio]:checked").should("have.length", 1); + }); +}); diff --git a/apps/web/cypress/e2e/account.cy.ts b/apps/web/cypress/e2e/account.cy.ts index 65a79da..832d19e 100644 --- a/apps/web/cypress/e2e/account.cy.ts +++ b/apps/web/cypress/e2e/account.cy.ts @@ -1,6 +1,9 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// 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. +// +// The account-deletion journey (wrong password, success, cancel) moved to +// account.feature — this file now only covers what's left: passive display. const authenticatedProfile = { id: 1, @@ -24,45 +27,4 @@ describe("Account settings (/parametres/compte)", () => { cy.contains("Martin").should("be.visible"); cy.contains("alice@example.com").should("be.visible"); }); - - it("shows an error and keeps the session when the password is wrong", () => { - cy.intercept("DELETE", "**/auth/me", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, - }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("wrong-password"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - cy.url().should("include", "/parametres/compte"); - }); - - it("deletes the account and returns to the login page", () => { - cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("correct-horse-battery-staple"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount") - .its("request.body") - .should("deep.equal", { password: "correct-horse-battery-staple" }); - cy.url().should("include", "/login"); - }); - - it("cancels the deletion without calling the API", () => { - cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.contains("button", "Annuler").click(); - - cy.contains("button", "Confirmer la suppression").should("not.exist"); - cy.get("@deleteAccount.all").should("have.length", 0); - }); }); diff --git a/apps/web/cypress/e2e/account.feature b/apps/web/cypress/e2e/account.feature new file mode 100644 index 0000000..7cedfa4 --- /dev/null +++ b/apps/web/cypress/e2e/account.feature @@ -0,0 +1,34 @@ +Feature: Account deletion + As a signed-in user + I want to permanently delete my account + So that I stay in control of my data + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Shows an error and keeps the session when the password is wrong + Given the account deletion request will fail because the credentials are invalid + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "wrong-password" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made + And I should see "Email ou mot de passe incorrect" + And the URL should include "/parametres/compte" + + Scenario: Deletes the account and returns to the login page + Given the account deletion request will succeed + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "correct-horse-battery-staple" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made with password "correct-horse-battery-staple" + And the URL should include "/login" + + Scenario: Cancels the deletion without calling the API + Given the account deletion request is being watched + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I click the button "Annuler" + Then I should not see "Confirmer la suppression" + And the account deletion request should not have been made diff --git a/apps/web/cypress/e2e/account.ts b/apps/web/cypress/e2e/account.ts new file mode 100644 index 0000000..a3f0163 --- /dev/null +++ b/apps/web/cypress/e2e/account.ts @@ -0,0 +1,32 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; +import { ErrorCode } from "@batch-cooking/shared"; + +Given("the account deletion request will fail because the credentials are invalid", () => { + cy.intercept("DELETE", "**/auth/me", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, + }).as("deleteAccount"); +}); + +Given("the account deletion request will succeed", () => { + cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); +}); + +Given("the account deletion request is being watched", () => { + cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); +}); + +Then("the account deletion request should have been made", () => { + cy.wait("@deleteAccount"); +}); + +Then( + "the account deletion request should have been made with password {string}", + (password: string) => { + cy.wait("@deleteAccount").its("request.body").should("deep.equal", { password }); + }, +); + +Then("the account deletion request should not have been made", () => { + cy.get("@deleteAccount.all").should("have.length", 0); +}); diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts deleted file mode 100644 index e0baebd..0000000 --- a/apps/web/cypress/e2e/auth.cy.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — this job doesn't run a live backend (see -// .github/workflows/ci.yml), and it keeps these specs focused on frontend -// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber -// suites against a real database. - -describe("Signup", () => { - it("creates a profile and starts the onboarding wizard (regime/household/allergens)", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - // The onboarding wizard's first step (see onboarding.cy.ts for the full - // walkthrough) is the regime step, which fetches the reference list. - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 201, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: null, - dietId: null, - }, - }).as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - - cy.wait("@signup"); - // Not the home page directly — signup hands off to the onboarding - // wizard first (RedirectIfAuthenticated no longer applies here, it's a - // RequireAuth-gated route of its own, see App.tsx). - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 sur 3").should("be.visible"); - }); - - it("shows a client-side validation error without calling the API", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup").as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("A"); - cy.get("#lastName").type("B"); - cy.get("#email").type("a@example.com"); - cy.get("#password").type("short"); - cy.contains("button", "Créer mon profil").click(); - - cy.contains("8 caractères minimum").should("be.visible"); - cy.get("@signup.all").should("have.length", 0); - }); - - it("shows the API's error when the email is already taken", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 409, - body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" }, - }).as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - - cy.wait("@signup"); - cy.contains("Cet email est déjà utilisé").should("be.visible"); - }); -}); - -describe("Login", () => { - it("logs in and lands on the home page", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/login", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("shows an error on invalid credentials", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/login", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("wrong-password"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - }); -}); - -describe("Already authenticated", () => { - it("redirects away from /login to the home page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - - cy.visit("/login"); - cy.url().should("not.include", "/login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("logs out and returns to the login page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.visit("/"); - // "Se déconnecter" lives inside the account menu, opened by clicking - // the greeting button — see AppLayout.tsx's AccountMenu. - cy.contains("button", "Bonjour Alice").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); -}); diff --git a/apps/web/cypress/e2e/auth.feature b/apps/web/cypress/e2e/auth.feature new file mode 100644 index 0000000..dc3763a --- /dev/null +++ b/apps/web/cypress/e2e/auth.feature @@ -0,0 +1,63 @@ +Feature: Signup and login + As a visitor + I want to create a profile or log into an existing one + So that I can access my household's batch-cooking planning + + Background: + Given I am not signed in + + Scenario: Signing up creates a profile and starts the onboarding wizard + Given the signup request will succeed + And the diets reference list is empty + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + + Scenario: Signing up shows a client-side validation error without calling the API + Given the signup request is being watched + When I sign up with: + | firstName | A | + | lastName | B | + | email | a@example.com | + | password | short | + Then I should see "8 caractères minimum" + And the signup request should not have been made + + Scenario: Signing up shows the API's error when the email is already taken + Given the signup request will fail because the email is already used + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And I should see "Cet email est déjà utilisé" + + Scenario: Logging in lands on the home page + Given the login request will succeed + And the planning request returns nothing + When I log in with email "alice@example.com" and password "correct-horse-battery-staple" + Then the login request should have been made + And I should see "Bonjour Alice" + + Scenario: Logging in shows an error on invalid credentials + Given the login request will fail because the credentials are invalid + When I log in with email "alice@example.com" and password "wrong-password" + Then the login request should have been made + And I should see "Email ou mot de passe incorrect" + + Scenario: Logging out returns to the login page + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the planning request returns nothing + And the logout request will succeed + When I visit "/" + And I open the account menu + And I click the button "Se déconnecter" + Then the logout request should have been made + And the URL should include "/login" diff --git a/apps/web/cypress/e2e/auth.ts b/apps/web/cypress/e2e/auth.ts new file mode 100644 index 0000000..d70d1d3 --- /dev/null +++ b/apps/web/cypress/e2e/auth.ts @@ -0,0 +1,78 @@ +import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; +import { ErrorCode } from "@batch-cooking/shared"; + +const signupResponse = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null as number | null, + dietId: null, +}; + +Given("the signup request will succeed", () => { + cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); +}); + +Given("the signup request is being watched", () => { + cy.intercept("POST", "**/auth/signup").as("signup"); +}); + +Given("the signup request will fail because the email is already used", () => { + cy.intercept("POST", "**/auth/signup", { + statusCode: 409, + body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" }, + }).as("signup"); +}); + +When("I sign up with:", (dataTable: DataTable) => { + const { firstName, lastName, email, password } = dataTable.rowsHash(); + cy.visit("/signup"); + cy.get("#firstName").type(firstName); + cy.get("#lastName").type(lastName); + cy.get("#email").type(email); + cy.get("#password").type(password); + cy.contains("button", "Créer mon profil").click(); +}); + +Then("the signup request should have been made", () => { + cy.wait("@signup"); +}); + +Then("the signup request should not have been made", () => { + cy.get("@signup.all").should("have.length", 0); +}); + +Given("the login request will succeed", () => { + cy.intercept("POST", "**/auth/login", { + statusCode: 200, + body: { ...signupResponse, houseId: 1 }, + }).as("login"); +}); + +Given("the login request will fail because the credentials are invalid", () => { + cy.intercept("POST", "**/auth/login", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" }, + }).as("login"); +}); + +When("I log in with email {string} and password {string}", (email: string, password: string) => { + cy.visit("/login"); + cy.get("#email").type(email); + cy.get("#password").type(password); + cy.contains("button", "Se connecter").click(); +}); + +Then("the login request should have been made", () => { + cy.wait("@login"); +}); + +Given("the logout request will succeed", () => { + cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); +}); + +Then("the logout request should have been made", () => { + cy.wait("@logout"); +}); diff --git a/apps/web/cypress/e2e/household-settings.cy.ts b/apps/web/cypress/e2e/household-settings.cy.ts index eb4acd1..e8c5032 100644 --- a/apps/web/cypress/e2e/household-settings.cy.ts +++ b/apps/web/cypress/e2e/household-settings.cy.ts @@ -1,4 +1,10 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// 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. +// +// The household-management journey (create, join, rename, remove a member, +// delete, leave) moved to household-settings.feature — this file now only +// covers what's left: passive display of each screen's initial state. const adminProfile = { id: 1, @@ -33,51 +39,6 @@ describe("Household settings (/parametres/foyer) — no household yet", () => { cy.contains("Créer un foyer").should("be.visible"); cy.contains("Rejoindre un foyer").should("be.visible"); }); - - it("creates a household", () => { - const createdHouse = { - id: 1, - name: "Chez Alice", - adminId: 1, - inviteCode: "ABCD2345", - members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], - }; - // The page reloads `GET /house/current` right after creating succeeds — - // see the "deletes the household" test above for the same pattern. - let created = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: created ? createdHouse : null }); - }); - cy.intercept("POST", "**/house", (req) => { - created = true; - req.reply({ statusCode: 201, body: createdHouse }); - }).as("createHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - cy.contains("ABCD2345").should("be.visible"); - }); - - it("joins a household by invite code", () => { - let joined = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); - }); - cy.intercept("POST", "**/house/join", (req) => { - joined = true; - req.reply({ statusCode: 200, body: houseWithTwoMembers }); - }).as("joinHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.contains("Bob Dupont").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as the admin", () => { @@ -94,68 +55,6 @@ describe("Household settings (/parametres/foyer) — as the admin", () => { cy.contains("Bob Dupont").should("be.visible"); cy.contains("Alice Martin").parent().contains("Admin"); }); - - it("autosaves the household name", () => { - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { ...houseWithTwoMembers, name: "Chez les Martin" }, - }).as("renameHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").clear(); - cy.get("#houseName").type("Chez les Martin"); - - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); - cy.contains("Enregistré ✓").should("be.visible"); - }); - - it("removes a member", () => { - // Same reasoning as the "deletes the household" test below — the page - // reloads `GET /house/current` right after the removal succeeds. - let memberRemoved = false; - cy.intercept("GET", "**/house/current", (req) => { - const body = memberRemoved - ? { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] } - : houseWithTwoMembers; - req.reply({ statusCode: 200, body }); - }); - cy.intercept("DELETE", "**/house/members/2", (req) => { - memberRemoved = true; - req.reply({ - statusCode: 200, - body: { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }, - }); - }).as("removeMember"); - - cy.visit("/parametres/foyer"); - cy.contains("li", "Bob Dupont").contains("button", "Retirer").click(); - - cy.wait("@removeMember"); - cy.contains("Bob Dupont").should("not.exist"); - }); - - it("deletes the household after confirming", () => { - // The page reloads `GET /house/current` right after the delete - // succeeds — this intercept needs to answer differently before/after - // that DELETE, hence the shared mutable flag rather than two static - // `cy.intercept` calls (the later one would just win for every request, - // including the initial page load). - let houseDeleted = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); - }); - cy.intercept("DELETE", "**/house/current", (req) => { - houseDeleted = true; - req.reply({ statusCode: 204 }); - }).as("deleteHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Supprimer le foyer").click(); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteHouse"); - cy.contains("Créer un foyer").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as a non-admin member", () => { @@ -173,13 +72,4 @@ describe("Household settings (/parametres/foyer) — as a non-admin member", () cy.contains("button", "Quitter le foyer").should("be.visible"); cy.contains("button", "Supprimer le foyer").should("not.exist"); }); - - it("leaves the household", () => { - cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Quitter le foyer").click(); - - cy.wait("@leaveHouse"); - }); }); diff --git a/apps/web/cypress/e2e/household-settings.feature b/apps/web/cypress/e2e/household-settings.feature new file mode 100644 index 0000000..078cdc7 --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.feature @@ -0,0 +1,64 @@ +Feature: Household settings + As a signed-in user + I want to create, join, manage, or leave a household + So that I can share a batch-cooking plan with the people I cook with + + Scenario: Creates a household + Given I am signed in as "Alice" "Martin" + And creating a household will succeed + When I visit "/parametres/foyer" + And I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And I should see "ABCD2345" + + Scenario: Joins a household by invite code + Given I am signed in as "Alice" "Martin" + And joining a household will succeed + When I visit "/parametres/foyer" + And I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And I should see "Bob Dupont" + + Scenario: Autosaves the household name + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And renaming the household will succeed + When I visit "/parametres/foyer" + And I clear the "houseName" field + And I fill in the "houseName" field with "Chez les Martin" + Then the household rename request should have been made with name "Chez les Martin" + And I should see "Enregistré ✓" + + Scenario: Removes a member + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And removing Bob from the household will succeed + When I visit "/parametres/foyer" + And I click "Retirer" for the member "Bob Dupont" + Then the member removal request should have been made + And I should not see "Bob Dupont" + + Scenario: Deletes the household after confirming + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And deleting the household will succeed + When I visit "/parametres/foyer" + And I click the button "Supprimer le foyer" + And I click the button "Confirmer la suppression" + Then the household deletion request should have been made + And I should see "Créer un foyer" + + Scenario: Leaves the household + Given I am signed in as "Bob" "Dupont" + And my user id is 2 + And my household id is 1 + And the household request returns the two-member household + And leaving the household will succeed + When I visit "/parametres/foyer" + And I click the button "Quitter le foyer" + Then the household leave request should have been made diff --git a/apps/web/cypress/e2e/household-settings.ts b/apps/web/cypress/e2e/household-settings.ts new file mode 100644 index 0000000..6618a1d --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.ts @@ -0,0 +1,74 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const houseWithTwoMembers = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], +}; + +Given("the household request returns the two-member household", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers }); +}); + +// Creating/joining a household, and asserting on those two requests, is +// shared with onboarding.feature's household step — see +// cypress/support/step_definitions/household-mutations.steps.ts. + +Given("renaming the household will succeed", () => { + cy.intercept("PATCH", "**/house/current", { + statusCode: 200, + body: { ...houseWithTwoMembers, name: "Chez les Martin" }, + }).as("renameHouse"); +}); + +Given("removing Bob from the household will succeed", () => { + const householdWithoutBob = { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }; + let memberRemoved = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: memberRemoved ? householdWithoutBob : houseWithTwoMembers }); + }); + cy.intercept("DELETE", "**/house/members/2", (req) => { + memberRemoved = true; + req.reply({ statusCode: 200, body: householdWithoutBob }); + }).as("removeMember"); +}); + +Given("deleting the household will succeed", () => { + let houseDeleted = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); + }); + cy.intercept("DELETE", "**/house/current", (req) => { + houseDeleted = true; + req.reply({ statusCode: 204 }); + }).as("deleteHouse"); +}); + +Given("leaving the household will succeed", () => { + cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); +}); + +When("I click {string} for the member {string}", (action: string, member: string) => { + cy.contains("li", member).contains("button", action).click(); +}); + +Then("the household rename request should have been made with name {string}", (name: string) => { + cy.wait("@renameHouse").its("request.body").should("deep.equal", { name }); +}); + +Then("the member removal request should have been made", () => { + cy.wait("@removeMember"); +}); + +Then("the household deletion request should have been made", () => { + cy.wait("@deleteHouse"); +}); + +Then("the household leave request should have been made", () => { + cy.wait("@leaveHouse"); +}); diff --git a/apps/web/cypress/e2e/layout.cy.ts b/apps/web/cypress/e2e/layout.cy.ts new file mode 100644 index 0000000..953f197 --- /dev/null +++ b/apps/web/cypress/e2e/layout.cy.ts @@ -0,0 +1,267 @@ +// 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)"); + }); + }); +}); diff --git a/apps/web/cypress/e2e/login-smoke.feature b/apps/web/cypress/e2e/login-smoke.feature deleted file mode 100644 index 0d0ef5c..0000000 --- a/apps/web/cypress/e2e/login-smoke.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Login screen (throwaway smoke test) - Just checking that Gherkin + Cypress actually work end to end with the - currently installed preprocessor version — not meant to stay in the - suite long-term. - - Scenario: A visitor can type their credentials into the login screen - Given I am not signed in - When I visit "/login" - And I fill in the "email" field with "alice@example.com" - And I fill in the "password" field with "correct-horse-battery-staple" - Then the "email" field should have the value "alice@example.com" - And the "password" field should have the value "correct-horse-battery-staple" diff --git a/apps/web/cypress/e2e/login-smoke.ts b/apps/web/cypress/e2e/login-smoke.ts deleted file mode 100644 index edcf30f..0000000 --- a/apps/web/cypress/e2e/login-smoke.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; - -// Throwaway smoke test — see login-smoke.feature. Steps are deliberately -// minimal/self-contained here rather than shared, since this whole file is -// meant to be deleted once it's proven the preprocessor works. - -Given("I am not signed in", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); -}); - -When("I visit {string}", (path: string) => { - cy.visit(path); -}); - -When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).type(value); -}); - -Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).should("have.value", value); -}); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts deleted file mode 100644 index 93c2af8..0000000 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). - -const signupResponse = { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: null as number | null, - dietId: null, -}; - -/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */ -function signupAndReachOnboarding() { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - cy.wait("@signup"); -} - -describe("Onboarding wizard (regime → foyer → allergens)", () => { - it("walks through all three steps, creating a household on the way, and lands on the home", () => { - cy.intercept("GET", "**/reference/diets", { - statusCode: 200, - body: [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, - ], - }); - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...signupResponse, dietId: 2 }, - }).as("updateDiet"); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house", { - statusCode: 201, - body: { id: 1, name: "Chez Alice", adminId: 1, inviteCode: "ABCD2345", members: [] }, - }).as("createHouse"); - cy.intercept("GET", "**/reference/allergies", { - statusCode: 200, - body: [ - { id: 1, key: "peanuts", kind: "ALLERGY" }, - { id: 2, key: "gluten", kind: "INTOLERANCE" }, - ], - }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - // Step 1/3 — dietary regime. - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 sur 3").should("be.visible"); - cy.get("#diet").select("Végétarien"); - cy.contains("button", "Continuer").click(); - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 }); - - // Step 2/3 — household, optional: creating one here. - cy.url().should("include", "/onboarding/foyer"); - cy.contains("Étape 2 sur 3").should("be.visible"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - - // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish. - cy.url().should("include", "/onboarding/allergenes"); - cy.contains("Étape 3 sur 3").should("be.visible"); - cy.contains("legend", "Allergies").should("be.visible"); - cy.contains("legend", "Intolérances").should("be.visible"); - cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - cy.contains("button", "Terminer").click(); - cy.wait("@updateAllergies") - .its("request.body") - .should("deep.equal", { allergyIds: [1] }); - - cy.url().should("eq", `${Cypress.config().baseUrl}/`); - cy.contains("h1", "Planning de la semaine").should("be.visible"); - }); - - it("lets the regime and allergens steps be skipped without changing anything", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as( - "updateDiet", - ); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - cy.url().should("include", "/onboarding/regime"); - cy.contains("button", "Continuer").click(); - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null }); - - cy.url().should("include", "/onboarding/foyer"); - cy.contains("button", "Passer cette étape").click(); - - cy.url().should("include", "/onboarding/allergenes"); - cy.contains("button", "Terminer").click(); - cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] }); - - cy.url().should("eq", `${Cypress.config().baseUrl}/`); - }); - - it("lets the household step be completed by joining an existing household instead of creating one", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house/join", { - statusCode: 200, - body: { - id: 1, - name: "Chez Bob", - adminId: 2, - inviteCode: "ABCD2345", - members: [ - { id: 1, firstName: "Alice", lastName: "Martin" }, - { id: 2, firstName: "Bob", lastName: "Dupont" }, - ], - }, - }).as("joinHouse"); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }); - - signupAndReachOnboarding(); - - cy.contains("button", "Continuer").click(); - cy.url().should("include", "/onboarding/foyer"); - - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.url().should("include", "/onboarding/allergenes"); - }); -}); diff --git a/apps/web/cypress/e2e/onboarding.feature b/apps/web/cypress/e2e/onboarding.feature new file mode 100644 index 0000000..c429a82 --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.feature @@ -0,0 +1,67 @@ +Feature: Onboarding wizard + As a newly signed-up user + I want to set my diet, household, and allergens + So that my profile is ready before I start planning meals + + Background: + Given the planning request returns nothing + + Scenario: Walks through all three steps, creating a household on the way, and lands on the home page + Given the diets reference list has options + And selecting the diet will succeed + And the household request returns no household + And creating a household will succeed + And the allergies reference list has options + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + When I select "Végétarien" from the "diet" field + And I click the button "Continuer" + Then the diet update request should have been made with diet id 2 + And the URL should include "/onboarding/foyer" + And I should see "Étape 2 sur 3" + When I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And the URL should include "/onboarding/allergenes" + And I should see "Étape 3 sur 3" + And I should see the section "Allergies" + And I should see the section "Intolérances" + When I check the checkbox "Arachides" + And I click the button "Terminer" + Then the allergies update request should have been made with allergy id 1 + And the URL should be the home page + And I should see the heading "Planning de la semaine" + + Scenario: Lets the regime and allergens steps be skipped without changing anything + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + When I click the button "Continuer" + Then the diet update request should have been made with no diet id + And the URL should include "/onboarding/foyer" + When I click the button "Passer cette étape" + Then the URL should include "/onboarding/allergenes" + When I click the button "Terminer" + Then the allergies update request should have been made with no allergy ids + And the URL should be the home page + + Scenario: Lets the household step be completed by joining an existing household instead of creating one + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And joining a household will succeed + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + When I click the button "Continuer" + Then the URL should include "/onboarding/foyer" + When I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And the URL should include "/onboarding/allergenes" diff --git a/apps/web/cypress/e2e/onboarding.ts b/apps/web/cypress/e2e/onboarding.ts new file mode 100644 index 0000000..7fba4e7 --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.ts @@ -0,0 +1,41 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +const signupResponse = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null as number | null, + dietId: null, +}; + +// Signs up and lands on the wizard's first step (regime) — shared setup for +// every scenario in this feature, mirroring `signupAndReachOnboarding` from +// the pre-conversion spec. +Given("I have signed up", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); + + cy.visit("/signup"); + cy.get("#firstName").type("Alice"); + cy.get("#lastName").type("Martin"); + cy.get("#email").type("alice@example.com"); + cy.get("#password").type("correct-horse-battery-staple"); + cy.contains("button", "Créer mon profil").click(); + cy.wait("@signup"); +}); + +Then("the diet update request should have been made with no diet id", () => { + cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null }); +}); + +Then("the allergies update request should have been made with allergy id {int}", (id: number) => { + cy.wait("@updateAllergies") + .its("request.body") + .should("deep.equal", { allergyIds: [id] }); +}); + +Then("the allergies update request should have been made with no allergy ids", () => { + cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] }); +}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index ab13c85..bfb4af3 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -1,6 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). +// 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. +// +// The logout journey (also reachable from here, via the account menu) moved +// to auth.feature — same behavior, no need to cover it twice. const authenticatedProfile = { id: 1, @@ -47,16 +50,6 @@ describe("Sidebar navigation", () => { cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.contains("h1", "Planning de la semaine").should("be.visible"); }); - - it("shows the signed-in user's name and lets them log out from the account menu", () => { - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.contains("button", "Bonjour Alice").should("be.visible").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); }); describe("Planning grid", () => { diff --git a/apps/web/cypress/e2e/preferences.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts index 65f3111..439bb91 100644 --- a/apps/web/cypress/e2e/preferences.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// 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. +// +// The autosave journeys (regime, allergies) moved to preferences.feature — +// this file now only covers the page's initial display. const authenticatedProfile = { id: 1, @@ -52,30 +57,4 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => { cy.visit("/parametres/preferences"); cy.contains("button", "Enregistrer").should("not.exist"); }); - - it("autosaves the regime as soon as it's selected", () => { - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...authenticatedProfile, dietId: 1 }, - }).as("updateDiet"); - - cy.visit("/parametres/preferences"); - cy.get("#diet").select("Omnivore"); - - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 }); - }); - - it("autosaves allergies and intolerances together after checking boxes", () => { - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( - "updateAllergies", - ); - - cy.visit("/parametres/preferences"); - cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - - cy.wait("@updateAllergies") - .its("request.body") - .should("deep.equal", { allergyIds: [2, 1] }); - cy.contains("Enregistré ✓").should("be.visible"); - }); }); diff --git a/apps/web/cypress/e2e/preferences.feature b/apps/web/cypress/e2e/preferences.feature new file mode 100644 index 0000000..2c23866 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.feature @@ -0,0 +1,23 @@ +Feature: Dietary preferences + As a signed-in user + I want my regime and allergies/intolerances to autosave as I edit them + So that my preferences are always up to date without an explicit save step + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And my diet id is 2 + And the dietary preferences reference data is ready + + Scenario: Autosaves the regime as soon as it's selected + Given selecting the diet will succeed + When I visit "/parametres/preferences" + And I select "Omnivore" from the "diet" field + Then the diet update request should have been made with diet id 1 + + Scenario: Autosaves allergies and intolerances together after checking boxes + Given updating allergies will succeed + When I visit "/parametres/preferences" + And I check the checkbox "Arachides" + Then the allergies update request should have been made with allergy ids 2 and 1 + And I should see "Enregistré ✓" diff --git a/apps/web/cypress/e2e/preferences.ts b/apps/web/cypress/e2e/preferences.ts new file mode 100644 index 0000000..45bd632 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.ts @@ -0,0 +1,40 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// The page also loads the reference ingredient list + the profile's +// disliked-ingredients selection for `DislikedIngredientsField` — added +// alongside diets/allergies in the same `Promise.all` (see +// PreferencesPage.tsx), so both need mocking here too or that `Promise.all` +// rejects and the whole page renders its error state instead of the form, +// taking `#diet`/the allergy checkboxes down with it. +Given("the dietary preferences reference data is ready", () => { + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, + ], + }); + cy.intercept("GET", "**/reference/allergies", { + statusCode: 200, + body: [ + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, + ], + }); + cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +}); + +// Selecting the diet, updating allergies, and asserting on the diet update +// request are shared with onboarding.feature's regime/allergies steps — see +// cypress/support/step_definitions/profile-mutations.steps.ts. + +Then( + "the allergies update request should have been made with allergy ids {int} and {int}", + (first: number, second: number) => { + cy.wait("@updateAllergies") + .its("request.body") + .should("deep.equal", { allergyIds: [first, second] }); + }, +); diff --git a/apps/web/cypress/e2e/recipe-form.cy.ts b/apps/web/cypress/e2e/recipe-form.cy.ts deleted file mode 100644 index 44ae9fb..0000000 --- a/apps/web/cypress/e2e/recipe-form.cy.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). - -const authenticatedProfile = { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, -}; - -const tomato = { - id: 1, - key: "tomato", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; -const egg = { - id: 2, - key: "egg", - icon: "EGG", - category: "CREMERIE_FROMAGE", - subcategory: "OEUFS", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], -}; -const carrot = { - id: 3, - key: "carrot", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; - -const diets = [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, -]; - -function interceptCatalog() { - cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); - cy.intercept("GET", "**/reference/ingredients", { - statusCode: 200, - body: [tomato, egg, carrot], - }); - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); -} - -describe("Recipe form — associating ingredients", () => { - beforeEach(() => { - interceptCatalog(); - }); - - it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => { - cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe"); - - cy.visit("/recettes/nouvelle"); - - cy.get("#recipe-name").type("Salade de tomates"); - - cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat"); - cy.contains(".ingredient-picker__card", "Tomate").click(); - - // The card disappears from the picker once selected (excludeIds), and - // a row for it appears in the recipe's own ingredient list. - cy.contains(".ingredient-picker__card", "Tomate").should("not.exist"); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - - cy.get(".ingredient-row .ingredient-row__quantity").type("3"); - cy.get(".ingredient-row .ingredient-row__unit").type("unité"); - - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item textarea").type("Couper les tomates."); - - cy.contains("button", "Enregistrer").should("not.be.disabled").click(); - - cy.wait("@createRecipe") - .its("request.body") - .should("deep.include", { - name: "Salade de tomates", - ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }], - }); - cy.url().should("include", "/recettes/42"); - }); - - // Regression test for the exact bug reported: `crypto.randomUUID()` (used - // to mint each ingredient/step draft's client-only React key) throws - // outside a secure context — https, or literally the hostname - // `localhost` — so a LAN IP during on-device testing or a Capacitor - // WebView's `capacitor://` origin hit a black screen with "TypeError: - // crypto.randomUUID is not a function" the instant an ingredient was - // added. Cypress's own origin is secure, so this forces the same failure - // by deleting `crypto.randomUUID` before the app boots — see - // `apps/web/src/lib/client-key.ts`, which replaced it. - it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => { - cy.visit("/recettes/nouvelle", { - onBeforeLoad(win) { - Object.defineProperty(win.crypto, "randomUUID", { - value: undefined, - configurable: true, - }); - }, - }); - - cy.get("#recipe-name").type("Recette hors contexte sécurisé"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.contains(".ingredient-picker__card", "Œuf").click(); - - // Both rows rendered with distinct identities — no crash, no React - // "same key" warning silently collapsing one of them. - cy.get(".ingredient-row").should("have.length", 2); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - - cy.contains("button", "Ajouter une étape").click(); - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item").should("have.length", 2); - }); - - it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => { - cy.visit("/recettes/nouvelle"); - - cy.contains(".ingredient-picker__card", "Carotte").click(); - cy.contains(".ingredient-picker__card", "Carotte").should("not.exist"); - - cy.contains(".ingredient-row", "Carotte") - .find("button[title='Retirer cet ingrédient']") - .click(); - cy.contains(".ingredient-picker__card", "Carotte").should("be.visible"); - cy.get(".ingredient-row").should("have.length", 0); - }); - - it("preloads an existing recipe's ingredients when editing, and lets you add another", () => { - const existingRecipe = { - id: 7, - name: "Omelette", - description: null, - picture: null, - authorId: 1, - visibility: "PERSONAL", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], - isFavorite: false, - ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], - steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], - }; - cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); - cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); - - cy.visit("/recettes/7/modifier"); - - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.get(".ingredient-row").should("have.length", 2); - cy.get(".ingredient-row .ingredient-row__quantity").last().type("1"); - cy.get(".ingredient-row .ingredient-row__unit").last().type("unité"); - - cy.contains("button", "Enregistrer").click(); - - cy.wait("@updateRecipe") - .its("request.body.ingredients") - .should("deep.equal", [ - { ingredientId: 2, quantity: 3, unit: "unité" }, - { ingredientId: 1, quantity: 1, unit: "unité" }, - ]); - }); -}); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature new file mode 100644 index 0000000..604cebe --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -0,0 +1,69 @@ +Feature: Recipe form — associating ingredients + As a signed-in user + I want to build a recipe by picking ingredients, quantities, and steps + So that I can save a complete recipe in one form + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the ingredient and diet catalog is available + + Scenario: Adds an ingredient from the picker, fills its quantity/unit, and creates the recipe + Given creating the recipe will succeed and return id 42 + When I visit "/recettes/nouvelle" + And I fill in the "recipe-name" field with "Salade de tomates" + And I search the ingredient picker for "tomat" + And I select the ingredient "Tomate" from the picker + Then the ingredient "Tomate" should no longer be in the picker + And the recipe should include the ingredient "Tomate" + When I fill in the ingredient's quantity with "3" and unit "unité" + And I add a step + And I fill in the step description with "Couper les tomates." + Then the "Enregistrer" button should not be disabled + When I click the button "Enregistrer" + Then the recipe creation request should have included name "Salade de tomates" and ingredient 1 with quantity 3 and unit "unité" + And the URL should include "/recettes/42" + + # Regression test for the exact bug reported: `crypto.randomUUID()` (used + # to mint each ingredient/step draft's client-only React key) throws + # outside a secure context — https, or literally the hostname `localhost` + # — so a LAN IP during on-device testing or a Capacitor WebView's + # `capacitor://` origin hit a black screen with "TypeError: + # crypto.randomUUID is not a function" the instant an ingredient was + # added. Cypress's own origin is secure, so this forces the same failure + # by deleting `crypto.randomUUID` before the app boots — see + # `apps/web/src/lib/client-key.ts`, which replaced it. + Scenario: Still works when crypto.randomUUID is unavailable (insecure-context regression) + When I visit the new recipe form without a secure random UUID + And I fill in the "recipe-name" field with "Recette hors contexte sécurisé" + And I select the ingredient "Tomate" from the picker + And I select the ingredient "Œuf" from the picker + Then there should be 2 ingredient rows + And the recipe should include the ingredient "Tomate" + And the recipe should include the ingredient "Œuf" + When I add a step + And I add a step + Then there should be 2 step editor items + + Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back + When I visit "/recettes/nouvelle" + And I select the ingredient "Carotte" from the picker + Then the ingredient "Carotte" should no longer be in the picker + When I remove the ingredient "Carotte" from the recipe + Then the ingredient "Carotte" should be visible in the picker + And there should be 0 ingredient rows + + Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another + Given recipe 7 exists with an egg omelette + And updating recipe 7 will succeed + When I visit "/recettes/7/modifier" + Then the recipe should include the ingredient "Œuf" + And the ingredient's quantity should be "3" + When I select the ingredient "Tomate" from the picker + Then there should be 2 ingredient rows + When I fill in the last ingredient's quantity with "1" and unit "unité" + And I click the button "Enregistrer" + Then the recipe update request should have included these ingredients: + | ingredientId | quantity | unit | + | 2 | 3 | unité | + | 1 | 1 | unité | diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts new file mode 100644 index 0000000..962f16c --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -0,0 +1,159 @@ +import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const tomato = { + id: 1, + key: "tomato", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; +const egg = { + id: 2, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], +}; +const carrot = { + id: 3, + key: "carrot", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; + +const diets = [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, +]; + +Given("the ingredient and diet catalog is available", () => { + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] }); + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); +}); + +Given("creating the recipe will succeed and return id {int}", (id: number) => { + cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id } }).as("createRecipe"); +}); + +When("I visit the new recipe form without a secure random UUID", () => { + cy.visit("/recettes/nouvelle", { + onBeforeLoad(win) { + Object.defineProperty(win.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }, + }); +}); + +When("I search the ingredient picker for {string}", (text: string) => { + cy.get("input[placeholder='Rechercher un ingrédient…']").type(text); +}); + +When("I select the ingredient {string} from the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).click(); +}); + +Then("the ingredient {string} should no longer be in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("not.exist"); +}); + +Then("the ingredient {string} should be visible in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("be.visible"); +}); + +Then("the recipe should include the ingredient {string}", (name: string) => { + cy.contains(".ingredient-row__name", name).should("be.visible"); +}); + +Then("there should be {int} ingredient rows", (count: number) => { + cy.get(".ingredient-row").should("have.length", count); +}); + +When("I remove the ingredient {string} from the recipe", (name: string) => { + cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click(); +}); + +When( + "I fill in the ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").type(unit); + }, +); + +Then("the ingredient's quantity should be {string}", (quantity: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity); +}); + +When( + "I fill in the last ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").last().type(unit); + }, +); + +When("I add a step", () => { + cy.contains("button", "Ajouter une étape").click(); +}); + +When("I fill in the step description with {string}", (text: string) => { + cy.get(".step-list-editor__item textarea").type(text); +}); + +Then("there should be {int} step editor items", (count: number) => { + cy.get(".step-list-editor__item").should("have.length", count); +}); + +Then( + "the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}", + (name: string, ingredientId: number, quantity: number, unit: string) => { + cy.wait("@createRecipe") + .its("request.body") + .should("deep.include", { + name, + ingredients: [{ ingredientId, quantity, unit }], + }); + }, +); + +Given("recipe 7 exists with an egg omelette", () => { + const existingRecipe = { + id: 7, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], + isFavorite: false, + ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], + steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], + }; + cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); +}); + +Given("updating recipe 7 will succeed", () => { + cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); +}); + +Then( + "the recipe update request should have included these ingredients:", + (dataTable: DataTable) => { + const expected = dataTable.hashes().map((row) => ({ + ingredientId: Number(row.ingredientId), + quantity: Number(row.quantity), + unit: row.unit, + })); + cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected); + }, +); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index f7a065c..955f14e 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -1,6 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). +// 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. +// +// The favorite-toggle and delete-recipe journeys moved to recipes.feature — +// this file now only covers catalog browsing/display. const authenticatedProfile = { id: 1, @@ -200,41 +203,6 @@ describe("Recipe catalog", () => { cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); }); - it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); - - cy.visit("/recettes/2"); - - cy.contains(".recipe-table__name", "Omelette") - .find(".recipe-table__fav-mark") - .should("not.exist"); - - cy.get(".favorite-star-button").click(); - cy.wait("@favorite"); - - cy.get(".favorite-star-button").should("have.class", "is-favorite"); - cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist"); - }); - - it("deletes a recipe after a two-step confirmation, then clears the selection", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); - - cy.visit("/recettes/2"); - cy.contains(".recipe-detail-panel", "Omelette").should("be.visible"); - - cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click(); - cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); - cy.wait("@deleteRecipe"); - - cy.url().should("match", /\/recettes\/?$/); - cy.contains(".recipe-table__name", "Omelette").should("not.exist"); - cy.contains("Sélectionnez une recette dans le tableau").should("be.visible"); - }); - it("links the new-recipe button to the recipe form", () => { cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature new file mode 100644 index 0000000..18a0ea4 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.feature @@ -0,0 +1,33 @@ +Feature: Managing a recipe from the catalog + As a signed-in user + I want to favorite or delete one of my recipes + So that I can curate my catalog as it grows + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the disliked ingredients list is empty + + Scenario: Toggles a recipe's favorite from the detail panel and reflects it in the table + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And toggling recipe 2's favorite will succeed + When I visit "/recettes/2" + Then the recipe "Omelette" should not be marked as favorite + When I click the favorite star + Then the favorite request should have been made + And the favorite star should be marked as favorite + And the recipe "Omelette" should be marked as favorite + + Scenario: Deletes a recipe after a two-step confirmation, then clears the selection + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And deleting recipe 2 will succeed + When I visit "/recettes/2" + Then the recipe detail panel heading should be "Omelette" + When I click "Supprimer" in the recipe detail panel + And I confirm the deletion in the recipe detail panel + Then the delete request should have been made + And the URL should match the recipes list + And the recipe "Omelette" should not be visible in the table + And I should see "Sélectionnez une recette dans le tableau" diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts new file mode 100644 index 0000000..02c0e80 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.ts @@ -0,0 +1,103 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" }; + +const omelette = { + id: 2, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [oeufs], + diets: [], + isFavorite: false, +}; + +const omeletteDetail = { + ...omelette, + description: "Une omelette toute simple.", + ingredients: [ + { + ingredient: { + id: 10, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [oeufs], + diets: [], + }, + quantity: 3, + unit: "unité", + }, + ], + steps: [ + { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + ], +}; + +Given("the disliked ingredients list is empty", () => { + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +}); + +Given("the recipe catalog contains {string}", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); +}); + +Given("recipe 2's detail is available", () => { + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe"); +}); + +Given("toggling recipe 2's favorite will succeed", () => { + cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); +}); + +Given("deleting recipe 2 will succeed", () => { + cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); +}); + +Then("the recipe {string} should not be visible in the table", (name: string) => { + cy.contains(".recipe-table__name", name).should("not.exist"); +}); + +Then("the recipe {string} should be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("exist"); +}); + +Then("the recipe {string} should not be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist"); +}); + +Then("the recipe detail panel heading should be {string}", (text: string) => { + cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible"); +}); + +When("I click the favorite star", () => { + cy.get(".favorite-star-button").click(); +}); + +Then("the favorite request should have been made", () => { + cy.wait("@favorite"); +}); + +Then("the favorite star should be marked as favorite", () => { + cy.get(".favorite-star-button").should("have.class", "is-favorite"); +}); + +When("I click {string} in the recipe detail panel", (text: string) => { + cy.contains(".recipe-detail-panel__danger-button", text).click(); +}); + +When("I confirm the deletion in the recipe detail panel", () => { + cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); +}); + +Then("the delete request should have been made", () => { + cy.wait("@deleteRecipe"); +}); + +Then("the URL should match the recipes list", () => { + cy.url().should("match", /\/recettes\/?$/); +}); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts index 2d69946..56d38e3 100644 --- a/apps/web/cypress/e2e/sidebar.cy.ts +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -1,4 +1,6 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// 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. const authenticatedProfile = { id: 1, diff --git a/apps/web/cypress/e2e/user-preferences.cy.ts b/apps/web/cypress/e2e/user-preferences.cy.ts index 0c09b1c..5368f72 100644 --- a/apps/web/cypress/e2e/user-preferences.cy.ts +++ b/apps/web/cypress/e2e/user-preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// 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. +// +// The theme-switching journey moved to user-preferences.feature — this file +// now only covers the page's initial display (default/saved theme). const authenticatedProfile = { id: 1, @@ -35,20 +40,4 @@ describe("User preferences (/parametres/preferences-utilisateur)", () => { cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked"); cy.get("html").should("have.attr", "data-theme", "dark"); }); - - it("switching theme autosaves and applies immediately, no explicit save button", () => { - cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } }); - cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( - "updatePreferences", - ); - - cy.visit("/parametres/preferences-utilisateur"); - cy.contains("button", "Enregistrer").should("not.exist"); - - cy.contains("label", "Clair").click(); - - cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme: "LIGHT" }); - cy.contains("Enregistré ✓").should("be.visible"); - cy.get("html").should("have.attr", "data-theme", "light"); - }); }); diff --git a/apps/web/cypress/e2e/user-preferences.feature b/apps/web/cypress/e2e/user-preferences.feature new file mode 100644 index 0000000..4f4b996 --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.feature @@ -0,0 +1,17 @@ +Feature: User preferences (theme) + As a signed-in user + I want to switch between system/light/dark theme + So that the app matches my preference, applied immediately + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Switching theme autosaves and applies immediately, no explicit save button + Given my saved theme preference is "SYSTEM" + And updating the theme preference will succeed + When I visit "/parametres/preferences-utilisateur" + Then I should not see "Enregistrer" + When I click the radio "Clair" + Then the theme update request should have been made with theme "LIGHT" + And I should see "Enregistré ✓" + And the page theme should be "light" diff --git a/apps/web/cypress/e2e/user-preferences.ts b/apps/web/cypress/e2e/user-preferences.ts new file mode 100644 index 0000000..e888bcd --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.ts @@ -0,0 +1,15 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +Given("my saved theme preference is {string}", (theme: string) => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme } }); +}); + +Given("updating the theme preference will succeed", () => { + cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( + "updatePreferences", + ); +}); + +Then("the theme update request should have been made with theme {string}", (theme: string) => { + cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme }); +}); diff --git a/apps/web/cypress/support/component-index.html b/apps/web/cypress/support/component-index.html new file mode 100644 index 0000000..aad39e0 --- /dev/null +++ b/apps/web/cypress/support/component-index.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/apps/web/cypress/support/component.ts b/apps/web/cypress/support/component.ts new file mode 100644 index 0000000..38f5f07 --- /dev/null +++ b/apps/web/cypress/support/component.ts @@ -0,0 +1,15 @@ +import { mount } from "cypress/react18"; +// Same global stylesheet the real app loads (see src/main.tsx) — generic +// components (Checkbox, Radio, Dialog…) are styled through it, not their +// own scoped CSS, so mounting one without it would test unstyled markup. +import "../../src/styles/global.scss"; + +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add("mount", mount); diff --git a/apps/web/cypress/support/profile.ts b/apps/web/cypress/support/profile.ts new file mode 100644 index 0000000..a0236ed --- /dev/null +++ b/apps/web/cypress/support/profile.ts @@ -0,0 +1,33 @@ +import type { SafeUserProfile } from "@batch-cooking/shared"; + +/** + * Mutable per-scenario signed-in profile, built up across several `Given` + * steps (see common.steps.ts's "I am signed in as .../my household id + * is.../my diet id is...") before the final `cy.visit` — each step + * re-registers the `GET **\/auth/me` intercept with the updated shape, so + * only the last one (i.e. the fully assembled profile) is ever actually + * requested by the app. Reset before every scenario by the `Before` hook in + * common.steps.ts, so scenarios never leak state into one another. + */ +export let currentProfile: SafeUserProfile | null = null; + +export function resetProfile() { + currentProfile = null; +} + +export function buildProfile(overrides: Partial = {}): SafeUserProfile { + return { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, + ...overrides, + }; +} + +export function setCurrentProfile(profile: SafeUserProfile) { + currentProfile = profile; +} diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts new file mode 100644 index 0000000..b428189 --- /dev/null +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,191 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; +import { buildProfile, currentProfile, resetProfile, setCurrentProfile } from "../profile"; + +// Steps shared across every feature — signing in/out, navigation, and +// generic UI assertions/interactions phrased the same way regardless of +// which page they happen to run against. Anything specific to one feature +// (its own API responses, its own DOM structure) lives in that feature's +// own `.steps.ts` instead — same split as apps/api's +// step-definitions/ (shared "profile already exists" vs. feature-specific +// steps). +// +// Mocks the API via `cy.intercept` — this job doesn't run a live backend +// (see .github/workflows/ci.yml); apps/api's own Mocha/Cucumber suites +// cover real API behavior against a real database. + +Given("I am not signed in", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); +}); + +// No `Before()` hook for this reset (deliberately) — registering any +// Cucumber hook makes the preprocessor's browser runtime read +// `messages.HookType.{BEFORE,AFTER}_TEST_CASE` to report it, and that enum +// doesn't exist on the older, CommonJS-only `@cucumber/messages` this repo +// is pinned to (see `pnpm.overrides` in package.json, and this branch's +// commit history for why) — every scenario crashed on "Cannot read +// properties of undefined (reading 'BEFORE_TEST_CASE')" the moment this +// file registered one. Resetting right here instead, at the one step every +// profile-building chain always starts with, is equivalent for our +// purposes without needing a hook at all. +Given("I am signed in as {string} {string}", (firstName: string, lastName: string) => { + resetProfile(); + setCurrentProfile( + buildProfile({ + firstName, + lastName, + email: `${firstName.toLowerCase()}@example.com`, + }), + ); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +// Composable with the step above — re-registers the same intercept with an +// updated profile field. Order between these doesn't matter as long as they +// all run before the scenario's `visit`/`When` step: Cypress resolves +// multiple `cy.intercept` calls on the same route by giving the +// most-recently-registered one priority, so the final, fully-assembled +// profile is always what the app actually receives. +Given("my household id is {int}", (houseId: number) => { + if (!currentProfile) { + throw new Error('"my household id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, houseId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my diet id is {int}", (dietId: number) => { + if (!currentProfile) { + throw new Error('"my diet id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, dietId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my user id is {int}", (id: number) => { + if (!currentProfile) { + throw new Error('"my user id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, id }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +When("I visit {string}", (path: string) => { + cy.visit(path); +}); + +Then("the URL should include {string}", (fragment: string) => { + cy.url().should("include", fragment); +}); + +Then("the URL should not include {string}", (fragment: string) => { + cy.url().should("not.include", fragment); +}); + +Then("the URL should be the home page", () => { + cy.url().should("eq", `${Cypress.config().baseUrl}/`); +}); + +Then("I should see {string}", (text: string) => { + cy.contains(text).should("be.visible"); +}); + +Then("I should see the heading {string}", (text: string) => { + cy.contains("h1", text).should("be.visible"); +}); + +Then("I should not see {string}", (text: string) => { + cy.contains(text).should("not.exist"); +}); + +When("I click the button {string}", (text: string) => { + cy.contains("button", text).click(); +}); + +When("I click the link {string}", (text: string) => { + cy.contains("a", text).click(); +}); + +When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).type(value); +}); + +When("I clear the {string} field", (fieldId: string) => { + cy.get(`#${fieldId}`).clear(); +}); + +Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).should("have.value", value); +}); + +When("I select {string} from the {string} field", (value: string, fieldId: string) => { + cy.get(`#${fieldId}`).select(value); +}); + +Then("I should see the section {string}", (legend: string) => { + cy.contains("legend", legend).should("be.visible"); +}); + +Then("the checkbox {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); +}); + +Then("the checkbox {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("not.be.checked"); +}); + +When("I check the checkbox {string}", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").check(); +}); + +Then("the radio {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("be.checked"); +}); + +Then("the radio {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("not.be.checked"); +}); + +When("I click the radio {string}", (label: string) => { + cy.contains("label", label).click(); +}); + +Then("the page should have no theme override", () => { + cy.get("html").should("not.have.attr", "data-theme"); +}); + +Then("the page theme should be {string}", (theme: string) => { + cy.get("html").should("have.attr", "data-theme", theme); +}); + +// Freezes `Date` so "today"/"this week" assertions are deterministic +// instead of depending on the day the suite happens to run. +Given("today is frozen at {string}", (iso: string) => { + cy.clock(new Date(iso), ["Date"]); +}); + +Given("the viewport is {int} by {int}", (width: number, height: number) => { + cy.viewport(width, height); +}); + +Then("the {string} button should not be disabled", (text: string) => { + cy.contains("button", text).should("not.be.disabled"); +}); + +When("I open the account menu", () => { + cy.get(".app-sidebar__account-toggle").click(); +}); + +// Not "**/planning*" — that glob also matches the Vite dev request for +// planning-page.scss. Used by any feature that lands on the home page +// (Planning) but isn't itself testing the planning grid's content. +Given("the planning request returns nothing", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); +}); + +Given("the household request returns no household", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); +}); + +Then("the link {string} should point to {string}", (text: string, href: string) => { + cy.contains("a", text).should("have.attr", "href", href); +}); diff --git a/apps/web/cypress/support/step_definitions/household-mutations.steps.ts b/apps/web/cypress/support/step_definitions/household-mutations.steps.ts new file mode 100644 index 0000000..82c6aa4 --- /dev/null +++ b/apps/web/cypress/support/step_definitions/household-mutations.steps.ts @@ -0,0 +1,63 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across household-settings.feature (dedicated create/join scenarios) +// and onboarding.feature (household step of the wizard) — both need to mock +// the create/join mutations and their up-to-date `GET /house/current` +// follow-up the same way. + +const houseWithTwoMembers = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], +}; + +// The page reloads `GET /house/current` right after each mutation below +// succeeds — these intercepts need to answer differently before/after that +// follow-up GET, hence a shared mutable flag rather than a single static +// `cy.intercept` (a later static one would just win for every request, +// including the initial page load). + +Given("creating a household will succeed", () => { + const createdHouse = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], + }; + let created = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: created ? createdHouse : null }); + }); + cy.intercept("POST", "**/house", (req) => { + created = true; + req.reply({ statusCode: 201, body: createdHouse }); + }).as("createHouse"); +}); + +Given("joining a household will succeed", () => { + let joined = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); + }); + cy.intercept("POST", "**/house/join", (req) => { + joined = true; + req.reply({ statusCode: 200, body: houseWithTwoMembers }); + }).as("joinHouse"); +}); + +Then("the household creation request should have been made with name {string}", (name: string) => { + cy.wait("@createHouse").its("request.body").should("deep.equal", { name }); +}); + +Then( + "the household join request should have been made with invite code {string}", + (code: string) => { + cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: code }); + }, +); diff --git a/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts b/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts new file mode 100644 index 0000000..3774aaf --- /dev/null +++ b/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts @@ -0,0 +1,21 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across preferences.feature (dedicated autosave scenarios) and +// onboarding.feature (regime/allergies steps of the wizard) — both need the +// same diet/allergies PATCH mocks and their request-body assertions. + +Given("selecting the diet will succeed", () => { + cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: { dietId: 1 } }).as( + "updateDiet", + ); +}); + +Then("the diet update request should have been made with diet id {int}", (dietId: number) => { + cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId }); +}); + +Given("updating allergies will succeed", () => { + cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( + "updateAllergies", + ); +}); diff --git a/apps/web/cypress/support/step_definitions/reference-data.steps.ts b/apps/web/cypress/support/step_definitions/reference-data.steps.ts new file mode 100644 index 0000000..732da0e --- /dev/null +++ b/apps/web/cypress/support/step_definitions/reference-data.steps.ts @@ -0,0 +1,33 @@ +import { Given } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across auth.feature (signup) and onboarding.feature (wizard) — both +// need the diets/allergies reference lists mocked before reaching a step +// that reads them, in either their "has options" or "is empty" shape. + +Given("the diets reference list has options", () => { + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, + ], + }); +}); + +Given("the diets reference list is empty", () => { + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); +}); + +Given("the allergies reference list has options", () => { + cy.intercept("GET", "**/reference/allergies", { + statusCode: 200, + body: [ + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, + ], + }); +}); + +Given("the allergies reference list is empty", () => { + cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 95cb204..a2f80dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "test": "echo \"no unit tests yet\" && exit 0", "cy:open": "cypress open", "cy:run": "cypress run", - "e2e": "start-server-and-test dev http://localhost:5173 cy:run" + "e2e": "start-server-and-test dev http://localhost:5173 cy:run", + "cy:run:component": "cypress run --component" }, "dependencies": { "@batch-cooking/date-tools": "workspace:*", @@ -26,6 +27,7 @@ "devDependencies": { "@badeball/cypress-cucumber-preprocessor": "22.2.0", "@bahmutov/cypress-esbuild-preprocessor": "2.2.8", + "@cypress/vite-dev-server": "5.2.1", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523baa5..7560769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@bahmutov/cypress-esbuild-preprocessor': specifier: 2.2.8 version: 2.2.8(esbuild@0.21.5) + '@cypress/vite-dev-server': + specifier: 5.2.1 + version: 5.2.1 '@types/node': specifier: ^22.9.0 version: 22.20.1 @@ -488,6 +491,9 @@ packages: resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==, tarball: https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz} engines: {node: '>= 6'} + '@cypress/vite-dev-server@5.2.1': + resolution: {integrity: sha512-5HEUpB2UjpoBByOPAdTBfeJWHlvyDv3Qz5GuGovoiZnzsZyF9eivWfFiYadFdjXXX8i8kVzibo8heWZg+jigGg==, tarball: https://registry.npmjs.org/@cypress/vite-dev-server/-/vite-dev-server-5.2.1.tgz} + '@cypress/xvfb@1.2.4': resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==, tarball: https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz} @@ -1439,6 +1445,9 @@ packages: resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, tarball: https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz} @@ -1703,6 +1712,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==, tarball: https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==, tarball: https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz} + engines: {node: '>= 6'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz} @@ -1857,6 +1873,19 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==, tarball: https://registry.npmjs.org/diff/-/diff-7.0.0.tgz} engines: {node: '>=0.3.1'} + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, tarball: https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, tarball: https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, tarball: https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz} engines: {node: '>=12'} @@ -1907,6 +1936,9 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, tarball: https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz} engines: {node: '>=8.6'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==, tarball: https://registry.npmjs.org/entities/-/entities-7.0.1.tgz} engines: {node: '>=0.12'} @@ -2084,6 +2116,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz} engines: {node: '>=10'} + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==, tarball: https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + flat@5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==, tarball: https://registry.npmjs.org/flat/-/flat-5.0.2.tgz} hasBin: true @@ -2546,6 +2582,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==, tarball: https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz} @@ -2779,6 +2819,9 @@ packages: encoding: optional: true + node-html-parser@5.3.3: + resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz} + node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz} engines: {node: '>=18'} @@ -2808,6 +2851,9 @@ packages: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==, tarball: https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz} deprecated: This package is no longer supported. + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} engines: {node: '>=0.10.0'} @@ -2846,10 +2892,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==, tarball: https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz} engines: {node: '>=10'} @@ -2881,6 +2935,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, tarball: https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz} engines: {node: '>=0.10.0'} @@ -3720,6 +3778,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz} + engines: {node: '>=12.20'} + yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} @@ -4098,6 +4160,15 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 + '@cypress/vite-dev-server@5.2.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + find-up: 6.3.0 + node-html-parser: 5.3.3 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -4881,6 +4952,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -5125,6 +5198,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.2.3: {} cypress@13.17.0: @@ -5328,6 +5411,24 @@ snapshots: diff@7.0.0: {} + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -5377,6 +5478,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@2.2.0: {} + entities@7.0.1: {} env-paths@2.2.1: {} @@ -5682,6 +5785,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + flat@5.0.2: {} follow-redirects@1.16.0(debug@4.4.3): @@ -6141,6 +6249,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -6360,6 +6472,11 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-html-parser@5.3.3: + dependencies: + css-select: 4.3.0 + he: 1.2.0 + node-releases@2.0.53: {} node-source-walk@7.0.2: @@ -6389,6 +6506,10 @@ snapshots: gauge: 3.0.2 set-blocking: 2.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6427,10 +6548,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 @@ -6462,6 +6591,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -7340,6 +7471,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yup@1.6.1: dependencies: property-expr: 2.0.6