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