From d0c592bfc1cc25675b5cecd0f1319f34cf8874f6 Mon Sep 17 00:00:00 2001 From: AbdullahM07 Date: Wed, 12 Aug 2026 02:55:54 +0300 Subject: [PATCH] fix(deployments): keep the filters when you come back from a deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter the deployments list by a project, open one of the rows, press Back — and the list returned showing every project again. Status and search did the same. The filters were component state only, and Back remounts the component, so each one reset to its default. A list you had deliberately narrowed silently became the full one, which is worse than losing the filter: the rows on screen looked like an answer to the question you had asked. They now live in the query string (`?status=`, `?project=`, `?q=`), so Back returns to the URL you left and the state comes back with it — the same URL-as-state approach the server detail page already uses for `?tab=`. Details worth knowing: - A filter at its default is absent from the URL, not `?status=all`, so an unfiltered visit leaves a clean address and never rewrites it. - The sync bails when the query string is already correct. That is the mount case, so restoring from a URL performs no navigation, and the effect cannot loop against its own dependency on searchParams. - `replace`, not `push`: filtering does not deserve history entries, and Back should leave the list, not step back through filter changes. `scroll: false` so re-filtering does not jump to the top. - An unrecognised status falls back to "all" rather than filtering against a value nothing matches, so a stale or hand-edited link cannot show an empty list. - Unrelated query params are preserved. Scoped to the standalone /deployments view. Embedded in a project the list is already that project's, the project selector is hidden, and that page owns its URL — it rewrites it to /projects/:id/:tab after reading its own params, which would strip anything written here and fight the sync. Tests mount the real component and drive the reported repro. Verified they catch the regression: with the change reverted, 5 of the 7 fail, including restoring the project filter. Adds happy-dom, needed to mount a component whose state depends on effects — the existing renderToStaticMarkup helpers run none. The row overflow menu is stubbed because it imports utils/icons, JSX inside a .js that the test transform cannot parse; the card itself stays real, since the card is what proves a filter was applied. --- apps/dashboard/package.json | 1 + .../components/DeploymentsContent.tsx | 84 +++++++- .../filters-url-state.render.test.tsx | 195 ++++++++++++++++++ bun.lock | 39 +++- 4 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/deployments/components/filters-url-state.render.test.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 7c87c0fae..3f95db21c 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -45,6 +45,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "happy-dom": "^20.11.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", diff --git a/apps/dashboard/src/app/(dashboard)/deployments/components/DeploymentsContent.tsx b/apps/dashboard/src/app/(dashboard)/deployments/components/DeploymentsContent.tsx index bc839c58f..6f1f2b19f 100644 --- a/apps/dashboard/src/app/(dashboard)/deployments/components/DeploymentsContent.tsx +++ b/apps/dashboard/src/app/(dashboard)/deployments/components/DeploymentsContent.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Rocket, Activity, CheckCircle2, XCircle, Loader2, Zap, ArrowRight } from "lucide-react"; import { deployApi, projectsApi } from "@/lib/api"; import { useI18n, interpolate } from "@/components/i18n-provider"; @@ -16,6 +17,40 @@ import { mapRowToDeployment, } from "../utils"; +type StatusFilter = + | "all" + | "success" + | "failed" + | "building" + | "pending" + | "canceled"; + +const STATUS_FILTERS: readonly StatusFilter[] = [ + "all", + "success", + "failed", + "building", + "pending", + "canceled", +]; + +/** Query keys the standalone /deployments view keeps its filters in. */ +const P_STATUS = "status"; +const P_PROJECT = "project"; +const P_QUERY = "q"; + +/** Only accept a status the filter actually has, so a hand-edited URL can't wedge + * the list on a value `filterDeployments` will never match. */ +function readStatus(raw: string | null): StatusFilter { + return STATUS_FILTERS.includes(raw as StatusFilter) ? (raw as StatusFilter) : "all"; +} + +/** Keep the URL clean: a filter at its default is absent, not `?status=all`. */ +function setOrDelete(params: URLSearchParams, key: string, value: string, dflt: string) { + if (value === dflt) params.delete(key); + else params.set(key, value); +} + interface DeploymentsContentProps { /** When set, scope to this project and hide the project selector */ projectId?: string; @@ -35,15 +70,54 @@ export const DeploymentsContent: React.FC = ({ }) => { const { t } = useI18n(); const isProject = !!projectId; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + /** + * Filters are mirrored into the URL on the standalone /deployments view, so + * opening a deployment and pressing Back returns to the list you were looking at. + * They used to be component state only: Back remounts this component, every + * filter reset to "all", and a filtered-down list silently became the full one. + * + * Scoped to the standalone view on purpose. Embedded in a project (`isProject`) + * the list is already one project's, the project selector is hidden, and that + * page owns its own URL — it rewrites it to `/projects/:id/:tab` after reading + * its params, which would strip anything written here and fight the sync below. + * + * Read once per mount: a Back navigation IS a fresh mount, which is exactly when + * the URL should seed the state. + */ + const urlFilters = !isProject; const [deployments, setDeployments] = useState([]); const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [filter, setFilter] = useState< - "all" | "success" | "failed" | "building" | "pending" | "canceled" - >("all"); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [filter, setFilter] = useState(() => + urlFilters ? readStatus(searchParams.get(P_STATUS)) : "all", + ); + const [searchQuery, setSearchQuery] = useState(() => + urlFilters ? (searchParams.get(P_QUERY) ?? "") : "", + ); + const [selectedProjectId, setSelectedProjectId] = useState(() => + urlFilters ? (searchParams.get(P_PROJECT) ?? "all") : "all", + ); + + // Write the current filters back to the URL. `replace`, not `push`, so filtering + // never builds up history entries the Back button has to chew through — and + // `scroll: false` so re-filtering doesn't jump the list to the top. Bails when the + // query string is already correct, which is the mount case: no redundant + // navigation, and no loop with the effect's own dependency on searchParams. + useEffect(() => { + if (!urlFilters) return; + const next = new URLSearchParams(Array.from(searchParams.entries())); + setOrDelete(next, P_STATUS, filter, "all"); + setOrDelete(next, P_QUERY, searchQuery, ""); + setOrDelete(next, P_PROJECT, selectedProjectId, "all"); + const qs = next.toString(); + if (qs === searchParams.toString()) return; + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [urlFilters, filter, searchQuery, selectedProjectId, searchParams, pathname, router]); const fetchDeployments = useCallback(async () => { setIsLoading(true); diff --git a/apps/dashboard/src/app/(dashboard)/deployments/components/filters-url-state.render.test.tsx b/apps/dashboard/src/app/(dashboard)/deployments/components/filters-url-state.render.test.tsx new file mode 100644 index 000000000..57949d934 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/deployments/components/filters-url-state.render.test.tsx @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom +/** + * Deployment filters must survive a Back navigation. + * + * Reported repro: open /deployments, filter by one project, click a deployment, hit + * Back — the list came back showing ALL projects. The filters were component state + * only, and Back remounts this component, so every one of them reset to its default + * and a deliberately narrowed list silently became the full one. + * + * The fix mirrors them into the query string, so this asserts both halves: changing + * a filter writes the URL, and mounting with that URL restores the filter (which is + * what a Back navigation actually does — it remounts at the previous URL). + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { I18nProvider } from "@/components/i18n-provider"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** Every URL the component asked the router to put in the address bar. */ +let replaced: string[] = []; +let searchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: (url: string) => replaced.push(url), + push: (url: string) => replaced.push(url), + back: () => {}, + refresh: () => {}, + }), + usePathname: () => "/deployments", + useSearchParams: () => searchParams, +})); + +// The row's overflow menu is stubbed for ONE reason: it imports `@/utils/icons`, +// which is JSX inside a .js file that the test transform cannot parse, so pulling it +// in takes down any test that renders a deployment card (the monitoring suite +// documents the same constraint). Nothing here asserts on the menu; the card itself +// stays real, because the card is what proves a filter was applied. +vi.mock("./DeploymentMenu", () => ({ DeploymentMenu: () => null })); + +const DEPLOYMENTS = [ + { + id: "d1", + projectId: "p1", + projectName: "alpha", + status: "success", + createdAt: "2026-08-11T10:00:00Z", + }, + { + id: "d2", + projectId: "p2", + projectName: "beta", + status: "failed", + createdAt: "2026-08-11T11:00:00Z", + }, +]; + +function stubFetch() { + return vi.fn(async (input: unknown) => { + const url = String(typeof input === "string" ? input : (input as Request)?.url ?? input); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + if (url.includes("deployments")) return json({ data: DEPLOYMENTS }); + return json({ data: [] }); + }); +} + +let container: HTMLDivElement; +let root: Root | undefined; +const errors: unknown[] = []; + +beforeEach(() => { + errors.length = 0; + replaced = []; + searchParams = new URLSearchParams(); + vi.stubGlobal("fetch", stubFetch()); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = undefined; + container.remove(); + vi.unstubAllGlobals(); +}); + +async function mountDeployments() { + const { DeploymentsContent } = await import("./DeploymentsContent"); + await act(async () => { + root = createRoot(container, { + onUncaughtError: (e) => errors.push(e), + onCaughtError: (e) => errors.push(e), + }); + root.render( + + + , + ); + }); + for (let i = 0; i < 3; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** Click the status-filter chip with this exact label. */ +async function clickChip(label: RegExp) { + const chip = Array.from(container.querySelectorAll("button")).find((b) => + label.test((b.textContent ?? "").trim()), + ); + expect(chip, `a filter chip matching ${label} should render`).toBeTruthy(); + await act(async () => { + chip!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("deployments filters ↔ URL", () => { + it("starts clean: no filter params for an unfiltered list", async () => { + await mountDeployments(); + expect(errors).toEqual([]); + // A default view must not rewrite the URL at all — otherwise every visit + // pushes ?status=all and the 'is anything filtered' check drifts. + expect(replaced).toEqual([]); + expect(container.textContent).toContain("alpha"); + expect(container.textContent).toContain("beta"); + }); + + it("writes the status filter to the URL when it changes", async () => { + await mountDeployments(); + await clickChip(/^failed$/i); + + expect(errors).toEqual([]); + expect(replaced.at(-1)).toBe("/deployments?status=failed"); + }); + + /** The actual regression: this is the state a Back navigation remounts into. */ + it("restores the project filter from the URL on mount", async () => { + searchParams = new URLSearchParams({ project: "p2" }); + await mountDeployments(); + + expect(errors).toEqual([]); + // Only the filtered project's deployment survives... + expect(container.textContent).toContain("beta"); + expect(container.textContent).not.toContain("alpha"); + // ...and restoring must not itself rewrite the URL. + expect(replaced).toEqual([]); + }); + + it("restores the status filter from the URL on mount", async () => { + searchParams = new URLSearchParams({ status: "failed" }); + await mountDeployments(); + + expect(errors).toEqual([]); + expect(container.textContent).toContain("beta"); + expect(container.textContent).not.toContain("alpha"); + expect(replaced).toEqual([]); + }); + + it("ignores a status the filter doesn't have instead of emptying the list", async () => { + searchParams = new URLSearchParams({ status: "not-a-status" }); + await mountDeployments(); + + expect(errors).toEqual([]); + // Falls back to "all" — a hand-edited or stale URL must not wedge the view on a + // value nothing will ever match. + expect(container.textContent).toContain("alpha"); + expect(container.textContent).toContain("beta"); + }); + + it("drops a filter param when it goes back to its default", async () => { + searchParams = new URLSearchParams({ status: "failed" }); + await mountDeployments(); + await clickChip(/^all$/i); + + expect(errors).toEqual([]); + // Back to the default → the param is removed, not left as ?status=all. + expect(replaced.at(-1)).toBe("/deployments"); + }); + + it("keeps unrelated query params intact", async () => { + searchParams = new URLSearchParams({ ref: "email" }); + await mountDeployments(); + await clickChip(/^failed$/i); + + expect(errors).toEqual([]); + expect(replaced.at(-1)).toBe("/deployments?ref=email&status=failed"); + }); +}); diff --git a/bun.lock b/bun.lock index 66a1761d6..aa89bdcef 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/api": { "name": "@repo/api", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@better-auth/drizzle-adapter": "^1.5.4", "@hono/node-server": "^1.19.15", @@ -51,7 +51,7 @@ }, "apps/cli": { "name": "openship", - "version": "0.6.1", + "version": "0.6.5", "bin": { "openship": "./dist/node-entry.js", }, @@ -109,6 +109,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "happy-dom": "^20.11.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", @@ -117,7 +118,7 @@ }, "apps/desktop": { "name": "@repo/desktop", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@repo/core": "workspace:*", "@repo/onboarding": "workspace:*", @@ -140,11 +141,11 @@ }, "apps/email": { "name": "@repo/email", - "version": "0.6.1", + "version": "0.6.5", }, "apps/web": { "name": "@repo/web", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@repo/core": "workspace:*", "@repo/ui": "workspace:*", @@ -1074,10 +1075,14 @@ "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1244,6 +1249,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], "bullmq": ["bullmq@5.70.4", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.9.3", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.4", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg=="], @@ -1502,7 +1509,7 @@ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1742,6 +1749,8 @@ "gsap": ["gsap@3.14.2", "", {}, "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA=="], + "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -2938,6 +2947,8 @@ "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -3120,6 +3131,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@types/ws/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "api/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3136,6 +3149,8 @@ "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-image-size/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "bullmq/ioredis": ["ioredis@5.9.3", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA=="], "bullmq/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -3228,6 +3243,10 @@ "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "happy-dom/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + + "happy-dom/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "load-json-file/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], @@ -3290,6 +3309,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-type/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -3430,6 +3451,8 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "api/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "api/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -3446,6 +3469,8 @@ "appdmg/execa/npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + "buffer-image-size/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3540,6 +3565,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "happy-dom/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "log-update/ansi-escapes/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],