From 0214dbfbe97330bb605c5b54d2e6fd2673b950a4 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 18:06:14 +0100 Subject: [PATCH 1/7] feat: add paginated queries for personal galleries --- .../src/graphql/resolvers/galleryResolvers.ts | 48 +++++++++++++++++++ backend/src/graphql/resolvers/index.ts | 1 + backend/src/graphql/typeDefs.ts | 13 +++++ 3 files changed, 62 insertions(+) diff --git a/backend/src/graphql/resolvers/galleryResolvers.ts b/backend/src/graphql/resolvers/galleryResolvers.ts index dfe4e16..7554d16 100644 --- a/backend/src/graphql/resolvers/galleryResolvers.ts +++ b/backend/src/graphql/resolvers/galleryResolvers.ts @@ -8,6 +8,54 @@ type Context = { export function createGalleryResolvers(db: Db) { return { + gallery: async ( + _parent: unknown, + { id, limit, offset }: { id: string; limit: number; offset: number }, + context: Context, + ) => { + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + if (limit < 1 || limit > 100 || offset < 0) { + throw new GraphQLError("Invalid pagination parameters"); + } + + if (!ObjectId.isValid(id)) { + return null; + } + + const gallery = await db.collection("galleries").findOne({ + _id: new ObjectId(id), + userId: new ObjectId(context.userId), + }); + + if (!gallery) { + return null; + } + + const collection = db.collection("met_artworks"); + const filter = { + id: { $in: gallery.artworkIds ?? [] }, + }; + + const [artworks, totalCount] = await Promise.all([ + collection + .find(filter) + .sort({ id: 1 }) + .skip(offset) + .limit(limit) + .toArray(), + collection.countDocuments(filter), + ]); + + return { + id: gallery._id.toString(), + title: gallery.title, + artworks, + totalCount, + }; + }, myGalleries: async (_parent: unknown, _args: unknown, context: Context) => { if (!context.userId || !ObjectId.isValid(context.userId)) { throw new GraphQLError("You must be logged in"); diff --git a/backend/src/graphql/resolvers/index.ts b/backend/src/graphql/resolvers/index.ts index 861f644..d68c39c 100644 --- a/backend/src/graphql/resolvers/index.ts +++ b/backend/src/graphql/resolvers/index.ts @@ -12,6 +12,7 @@ export function createResolvers(db: Db) { hello: () => "Hello from Artex backend!", ...createArtworkResolvers(db), myGalleries: galleryResolvers.myGalleries, + gallery: galleryResolvers.gallery, me: userResolvers.me, }, diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index f3fa358..9f2fe9e 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -39,6 +39,13 @@ export const typeDefs = `#graphql artworkIds: [Int!]! } + type GalleryPage { + id: ID! + title: String! + artworks: [Artwork!]! + totalCount: Int! + } + enum ArtworkSort { TITLE_ASC TITLE_DESC @@ -68,6 +75,12 @@ export const typeDefs = `#graphql ): [String!]! myGalleries: [Gallery!]! + + gallery( + id: ID! + limit: Int! + offset: Int! + ): GalleryPage } type Mutation { From e2991cb7393381906d93bae2a38170de3654b04d Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 18:11:12 +0100 Subject: [PATCH 2/7] feat: add hook for paginated gallery content --- frontend/src/hooks/useGallery.ts | 97 ++++++++++++++++++++++++++++++++ frontend/src/types/Gallery.ts | 9 +++ 2 files changed, 106 insertions(+) create mode 100644 frontend/src/hooks/useGallery.ts diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts new file mode 100644 index 0000000..3a43864 --- /dev/null +++ b/frontend/src/hooks/useGallery.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import type { GalleryPage } from "../types/Gallery"; +import { useAuthStore } from "../store/authStore"; + +type GalleryResponse = { + data?: { + gallery: GalleryPage | null; + }; + errors?: { message: string }[]; +}; + +export function useGallery(id: string, page: number, pageSize = 12) { + const token = useAuthStore((state) => state.token); + + const [gallery, setGallery] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + + async function fetchGallery() { + setLoading(true); + setError(""); + setGallery(null); + + try { + if (!token) { + throw new Error("You must be logged in"); + } + + const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + body: JSON.stringify({ + query: ` + query GetGallery($id: ID!, $limit: Int!, $offset: Int!) { + gallery(id: $id, limit: $limit, offset: $offset) { + id + title + totalCount + artworks { + id + title + artist_display + image_url + } + } + } + `, + variables: { + id, + limit: pageSize, + offset: (page - 1) * pageSize, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = (await response.json()) as GalleryResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + if (!result.data) { + throw new Error("Could not load gallery"); + } + + if (!controller.signal.aborted) { + setGallery(result.data.gallery); + } + } catch (err) { + if (controller.signal.aborted) return; + + setError(err instanceof Error ? err.message : "Could not load gallery"); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + void fetchGallery(); + + return () => controller.abort(); + }, [id, page, pageSize, token]); + + return { gallery, loading, error }; +} diff --git a/frontend/src/types/Gallery.ts b/frontend/src/types/Gallery.ts index 97c0150..9054198 100644 --- a/frontend/src/types/Gallery.ts +++ b/frontend/src/types/Gallery.ts @@ -1,6 +1,15 @@ +import type { Artwork } from "./Artwork"; + export type Gallery = { id: string; userId: string; title: string; artworkIds: number[]; }; + +export type GalleryPage = { + id: string; + title: string; + artworks: Artwork[]; + totalCount: number; +}; From 1941d30932e8c679d7cd7f29897aef9b4f2f7e63 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 20:57:46 +0100 Subject: [PATCH 3/7] feat: display personal galleries with paginated artworks --- frontend/src/App.tsx | 9 ++++ frontend/src/components/ArtworkCard.tsx | 5 +- frontend/src/components/GalleryCard.tsx | 11 ++++- frontend/src/pages/ArtworkDetailPage.tsx | 10 +++- frontend/src/pages/GalleryPage.tsx | 62 ++++++++++++++++++++++++ frontend/src/pages/ProfilePage.tsx | 1 + 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/GalleryPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f18051..de4536a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Login from "./pages/LoginPage"; import CreateAccount from "./pages/CreateAccountPage"; import ProfilePage from "./pages/ProfilePage"; import ArtworkDetailPage from "./pages/ArtworkDetailPage"; +import GalleryPage from "./pages/GalleryPage"; import Footer from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute"; @@ -28,6 +29,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/ArtworkCard.tsx index a5f007e..ed9aaa7 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/ArtworkCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import type { Artwork } from "../types/Artwork"; import "./ArtworkCard.css"; @@ -27,10 +27,11 @@ function ArtworkImage({ src, alt }: { src: string; alt: string }) { export default function ArtworkCard({ artwork }: ArtworkCardProps) { const artist = artwork.artist_display?.trim() || "Unknown artist"; + const location = useLocation(); return (
- + {artwork.image_url ? ( + {imageUrls.length === 0 ? (
No image yet @@ -59,6 +66,6 @@ export default function GalleryCard({

{artworkCount} {artworkCount === 1 ? "artwork" : "artworks"}

-
+ ); } diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index ab200d0..dca0323 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -1,4 +1,4 @@ -import { Link, useParams } from "react-router-dom"; +import { Link, useParams, useLocation } from "react-router-dom"; import { useArtwork } from "../hooks/useArtwork"; import { useState } from "react"; import AddToGalleryModal from "../components/AddToGalleryModal"; @@ -11,6 +11,12 @@ export default function ArtworkDetailPage() { const { artwork, loading, error } = useArtwork(artworkId); const [showGalleryModal, setShowGalleryModal] = useState(false); + const location = useLocation(); + const from = location.state?.from; + + const backTo = + typeof from === "string" && from.startsWith("/galleries/") ? from : "/"; + if (loading) { return (

@@ -37,7 +43,7 @@ export default function ArtworkDetailPage() { return (

← Back diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx new file mode 100644 index 0000000..0ed1640 --- /dev/null +++ b/frontend/src/pages/GalleryPage.tsx @@ -0,0 +1,62 @@ +import { Link, useParams } from "react-router-dom"; +import { useGallery } from "../hooks/useGallery"; +import { useState } from "react"; +import ArtworkGrid from "../components/ArtworkGrid"; +import Pagination from "../components/Pagination"; + +export default function GalleryPage() { + const { id = "" } = useParams(); + const [page, setPage] = useState(1); + const pageSize = 12; + + const { gallery, loading, error } = useGallery(id, page, pageSize); + const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize); + + return ( +
+ + ← Back to profile + + + {loading ? ( +

+ Loading gallery... +

+ ) : error ? ( +

+ Could not load gallery: {error} +

+ ) : !gallery ? ( +

Gallery not found

+ ) : ( +
+

{gallery.title}

+

+ {gallery.totalCount}{" "} + {gallery.totalCount === 1 ? "artwork" : "artworks"} +

+ {gallery.totalCount === 0 ? ( +

+ This gallery has no artworks yet. +

+ ) : ( + <> + + + {totalPages > 1 && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 9c14832..fdabf2e 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -367,6 +367,7 @@ export default function ProfilePage() { {galleries.map((gallery) => ( Date: Mon, 2 Nov 2026 21:20:45 +0100 Subject: [PATCH 4/7] test: cover gallery retrieval pagination and access control --- backend/tests/gallery.test.ts | 301 ++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 backend/tests/gallery.test.ts diff --git a/backend/tests/gallery.test.ts b/backend/tests/gallery.test.ts new file mode 100644 index 0000000..7097cd2 --- /dev/null +++ b/backend/tests/gallery.test.ts @@ -0,0 +1,301 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ApolloServer } from "@apollo/server"; +import { MongoClient, ObjectId } from "mongodb"; +import { randomUUID } from "node:crypto"; +import { typeDefs } from "../src/graphql/typeDefs.js"; +import { createResolvers } from "../src/graphql/resolvers/index.js"; + +type TestContext = { + userId: string | null; +}; + +const client = new MongoClient( + process.env.TEST_MONGODB_URI ?? "mongodb://127.0.0.1:27018", + { + serverSelectionTimeoutMS: 5000, + connectTimeoutMS: 5000, + }, +); + +const database = client.db(`artex_gallery_test_${randomUUID()}`); + +const ownerId = new ObjectId(); +const otherUserId = new ObjectId(); +const galleryId = new ObjectId(); +const emptyGalleryId = new ObjectId(); + +const server = new ApolloServer({ + typeDefs, + resolvers: createResolvers(database), +}); + +beforeAll(async () => { + await client.connect(); + + await database.collection("met_artworks").insertMany([ + { id: 30, title: "Third artwork" }, + { id: 10, title: "First artwork" }, + { id: 20, title: "Second artwork" }, + { id: 40, title: "Artwork outside the gallery" }, + ]); + + await database.collection("galleries").insertMany([ + { + _id: galleryId, + userId: ownerId, + title: "Test gallery", + artworkIds: [30, 10, 20], + }, + { + _id: emptyGalleryId, + userId: ownerId, + title: "Empty gallery", + artworkIds: [], + }, + ]); + + await server.start(); +}, 15000); + +afterAll(async () => { + try { + await server.stop(); + } finally { + try { + await database.dropDatabase(); + } finally { + await client.close(); + } + } +}, 15000); + +const galleryQuery = ` + query GetGallery($id: ID!, $limit: Int!, $offset: Int!) { + gallery(id: $id, limit: $limit, offset: $offset) { + id + title + totalCount + artworks { + id + title + } + } + } +`; + +describe("Gallery API", () => { + it("returns the owner's gallery with artworks ordered by ID", async () => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: galleryId.toString(), + limit: 10, + offset: 0, + }, + }, + { + contextValue: { userId: ownerId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.gallery).toEqual({ + id: galleryId.toString(), + title: "Test gallery", + totalCount: 3, + artworks: [ + { id: 10, title: "First artwork" }, + { id: 20, title: "Second artwork" }, + { id: 30, title: "Third artwork" }, + ], + }); + }); + + it("paginates gallery artworks while keeping the total count", async () => { + const pages = []; + + for (const offset of [0, 2]) { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: galleryId.toString(), + limit: 2, + offset, + }, + }, + { + contextValue: { userId: ownerId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.errors).toBeUndefined(); + pages.push(response.body.singleResult.data?.gallery); + } + + expect(pages).toEqual([ + { + id: galleryId.toString(), + title: "Test gallery", + totalCount: 3, + artworks: [ + { id: 10, title: "First artwork" }, + { id: 20, title: "Second artwork" }, + ], + }, + { + id: galleryId.toString(), + title: "Test gallery", + totalCount: 3, + artworks: [{ id: 30, title: "Third artwork" }], + }, + ]); + }); + + it("does not expose another user's gallery", async () => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: galleryId.toString(), + limit: 10, + offset: 0, + }, + }, + { + contextValue: { userId: otherUserId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.gallery).toBeNull(); + }); + + it("requires an authenticated user", async () => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: galleryId.toString(), + limit: 10, + offset: 0, + }, + }, + { + contextValue: { userId: null }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.data?.gallery).toBeNull(); + expect(response.body.singleResult.errors).toEqual([ + expect.objectContaining({ + message: "You must be logged in", + }), + ]); + }); + + it("returns an empty list and zero count for an empty gallery", async () => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: emptyGalleryId.toString(), + limit: 10, + offset: 0, + }, + }, + { + contextValue: { userId: ownerId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.gallery).toEqual({ + id: emptyGalleryId.toString(), + title: "Empty gallery", + totalCount: 0, + artworks: [], + }); + }); + + it.each([ + ["a nonexistent gallery", new ObjectId().toString()], + ["an invalid gallery ID", "invalid-id"], + ])("returns null for %s", async (_description, id) => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id, + limit: 10, + offset: 0, + }, + }, + { + contextValue: { userId: ownerId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.gallery).toBeNull(); + }); + + it.each([ + { limit: 0, offset: 0 }, + { limit: 101, offset: 0 }, + { limit: 2, offset: -1 }, + ])( + "rejects invalid pagination: limit=$limit, offset=$offset", + async ({ limit, offset }) => { + const response = await server.executeOperation( + { + query: galleryQuery, + variables: { + id: galleryId.toString(), + limit, + offset, + }, + }, + { + contextValue: { userId: ownerId.toString() }, + }, + ); + + if (response.body.kind !== "single") { + throw new Error("Expected a single GraphQL response"); + } + + expect(response.body.singleResult.data?.gallery).toBeNull(); + expect(response.body.singleResult.errors).toEqual([ + expect.objectContaining({ + message: "Invalid pagination parameters", + }), + ]); + }, + ); +}); From 6dd5d155d2c09283d27e9ca4b6cb2a6a11545739 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 21:51:14 +0100 Subject: [PATCH 5/7] test: add Vitest setup and gallery page tests --- frontend/package-lock.json | 1075 +++++++++++++++++++++++- frontend/package.json | 10 +- frontend/src/test/GalleryPage.test.tsx | 192 +++++ frontend/src/test/setup.ts | 7 + frontend/vitest.config.ts | 11 + 5 files changed, 1274 insertions(+), 21 deletions(-) create mode 100644 frontend/src/test/GalleryPage.test.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ccc63c..02a900a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,9 @@ "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", "@types/react": "^19.2.18", @@ -34,12 +37,21 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "jsdom": "^30.1.3", "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@apollo/client": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", @@ -77,6 +89,59 @@ } } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -316,6 +381,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -340,6 +418,146 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -588,6 +806,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -1549,6 +1785,113 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1594,6 +1937,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1932,6 +2293,54 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wry/caches": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", @@ -2020,32 +2429,77 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, + "peer": true, "engines": { - "node": ">=10", - "npm": ">=6" + "node": ">=8" } }, - "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/balanced-match": { + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", @@ -2068,6 +2522,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -2159,6 +2623,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2219,12 +2693,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2242,6 +2751,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2249,6 +2765,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2258,6 +2784,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2288,6 +2822,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2306,6 +2853,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2503,6 +3057,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2513,6 +3077,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2750,6 +3324,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2786,6 +3373,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2830,6 +3427,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2852,6 +3456,56 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3229,6 +3883,17 @@ } } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3238,12 +3903,29 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -3310,6 +3992,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optimism": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", @@ -3402,6 +4098,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz", + "integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3509,6 +4218,30 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3652,6 +4385,30 @@ "react-dom": ">=16.6.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.13", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", @@ -3724,6 +4481,19 @@ "tslib": "^2.1.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -3787,6 +4557,26 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3824,6 +4614,26 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3840,6 +4650,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3910,6 +4766,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -4049,6 +4915,147 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4065,6 +5072,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4075,6 +5095,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b6deb32..22d6202 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@apollo/client": "^4.3.3", @@ -26,6 +27,9 @@ "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", "@types/react": "^19.2.18", @@ -36,9 +40,11 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "jsdom": "^30.1.3", "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } } diff --git a/frontend/src/test/GalleryPage.test.tsx b/frontend/src/test/GalleryPage.test.tsx new file mode 100644 index 0000000..7d567be --- /dev/null +++ b/frontend/src/test/GalleryPage.test.tsx @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import GalleryPage from "../pages/GalleryPage"; +import { useGallery } from "../hooks/useGallery"; +import userEvent from "@testing-library/user-event"; + +vi.mock("../hooks/useGallery", () => ({ + useGallery: vi.fn(), +})); + +const mockedUseGallery = vi.mocked(useGallery); + +function renderGalleryPage() { + return render( + + + } /> + + , + ); +} + +describe("GalleryPage", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("shows a loading message while fetching the gallery", () => { + mockedUseGallery.mockReturnValue({ + gallery: null, + loading: true, + error: "", + }); + + renderGalleryPage(); + + expect(screen.getByRole("status")).toHaveTextContent( + "Loading gallery...", + ); + expect( + screen.queryByRole("heading", { name: "Gallery not found" }), + ).not.toBeInTheDocument(); + }); + + it("shows not found when the gallery does not exist", () => { + mockedUseGallery.mockReturnValue({ + gallery: null, + loading: false, + error: "", + }); + + renderGalleryPage(); + + expect( + screen.getByRole("heading", { name: "Gallery not found" }), + ).toBeInTheDocument(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("shows an error message when fetching the gallery fails", () => { + mockedUseGallery.mockReturnValue({ + gallery: null, + loading: false, + error: "Network error", + }); + + renderGalleryPage(); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not load gallery: Network error", + ); + + expect( + screen.queryByRole("heading", { name: "Gallery not found" }), + ).not.toBeInTheDocument(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows an empty message when the gallery has no artworks", () => { + mockedUseGallery.mockReturnValue({ + gallery: { + id: "test-gallery", + title: "My gallery", + totalCount: 0, + artworks: [], + }, + loading: false, + error: "", + }); + + renderGalleryPage(); + + expect( + screen.getByRole("heading", { name: "My gallery" }), + ).toBeInTheDocument(); + + expect( + screen.getByText("This gallery has no artworks yet."), + ).toBeInTheDocument(); + + expect(screen.getByText("0 artworks")).toBeInTheDocument(); + }); + + it("shows gallery content without pagination for one artwork", () => { + mockedUseGallery.mockReturnValue({ + gallery: { + id: "test-gallery", + title: "My gallery", + totalCount: 1, + artworks: [ + { + id: 1, + title: "Test artwork", + artist_display: "Test artist", + }, + ], + }, + loading: false, + error: "", + }); + + renderGalleryPage(); + + expect( + screen.getByRole("heading", { name: "My gallery" }), + ).toBeInTheDocument(); + + expect(screen.getByText("1 artwork")).toBeInTheDocument(); + + expect( + screen.getByRole("heading", { name: "Test artwork" }), + ).toBeInTheDocument(); + + expect(screen.getByText("Test artist")).toBeInTheDocument(); + + expect( + screen.queryByRole("navigation", { name: "Artwork pagination" }), + ).not.toBeInTheDocument(); + }); + + it("navigates between gallery pages", async () => { + const user = userEvent.setup(); + + mockedUseGallery.mockReturnValue({ + gallery: { + id: "test-gallery", + title: "My gallery", + totalCount: 13, + artworks: [ + { + id: 1, + title: "Test artwork", + artist_display: "Test artist", + }, + ], + }, + loading: false, + error: "", + }); + + renderGalleryPage(); + + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(mockedUseGallery).toHaveBeenLastCalledWith( + "test-gallery", + 2, + 12, + ); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Previous" })); + + expect(mockedUseGallery).toHaveBeenLastCalledWith( + "test-gallery", + 1, + 12, + ); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..1229971 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); \ No newline at end of file diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..f6d5579 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, +}); \ No newline at end of file From 1cfaeec9f8d2f783c515341dc8c19050fe876058 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 21:51:34 +0100 Subject: [PATCH 6/7] fix: close mobile search without an effect --- frontend/src/components/CatalogueControls.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 24a930b..91b6c9b 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -66,10 +66,6 @@ export default function CatalogueControls({ }; }, [artistSearchInput, showFilters]); - useEffect(() => { - if (paginationVisible) setMobileSearchOpen(false); - }, [paginationVisible]); - function handleSubmit(event: SubmitEvent) { event.preventDefault(); onSearch(searchInput); @@ -83,6 +79,10 @@ export default function CatalogueControls({ onReset(); } + if (paginationVisible && mobileSearchOpen) { + setMobileSearchOpen(false); + } + // AI was primary used here to get the styling we wanted for the filter options. return (
From 2f03eeb2569626b0475d00d9af4fd7c6214b5b16 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Mon, 2 Nov 2026 22:05:31 +0100 Subject: [PATCH 7/7] style: align gallery layout with the rest of the application --- frontend/src/pages/GalleryPage.tsx | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 0ed1640..e02bc9d 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -13,10 +13,10 @@ export default function GalleryPage() { const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize); return ( -
+
← Back to profile @@ -32,19 +32,26 @@ export default function GalleryPage() { ) : !gallery ? (

Gallery not found

) : ( -
-

{gallery.title}

-

- {gallery.totalCount}{" "} - {gallery.totalCount === 1 ? "artwork" : "artworks"} -

+
+
+

+ {gallery.title} +

+ +

+ {gallery.totalCount}{" "} + {gallery.totalCount === 1 ? "artwork" : "artworks"} +

+
{gallery.totalCount === 0 ? (

This gallery has no artworks yet.

) : ( <> - +
+ +
{totalPages > 1 && (