diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5a2278..b013153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,16 @@ jobs: - name: Typecheck (video) run: pnpm video:typecheck - - name: Unit tests - run: pnpm test + - name: Unit tests (coverage-gated) + run: pnpm test:coverage + + - name: Coverage summary + if: always() + run: | + if [ -f coverage/coverage-summary.json ]; then + echo '### Coverage' >> "$GITHUB_STEP_SUMMARY" + node -e 'const t=require("./coverage/coverage-summary.json").total; for (const k of ["lines","statements","functions","branches"]) console.log(`- ${k}: ${t[k].pct}%`)' >> "$GITHUB_STEP_SUMMARY" + fi - name: Build run: pnpm build diff --git a/.gitignore b/.gitignore index 8712ec9..e8602ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ out/ dist/ +coverage/ *.log .DS_Store .playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index 6643f0d..5a6a03a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,3 +45,18 @@ need changes for routine updates. for the inclusive breakpoints. The residual classes in globals.css (`lp-nav`, `reveal`, `lp-screen`, `blog-prose`, ...) are behavior hooks or markdown-facing; keep them stable. + +## Testing + +- Coverage is ratcheted: CI fails below the thresholds in `vitest.config.ts`. + CI's (Linux) numbers are authoritative β€” macOS measures slightly higher, so + don't copy local numbers. When a PR raises CI coverage, bump the thresholds + in the same PR; never lower them. +- New logic ships with tests in the same PR. Renderer hook tests use the typed + bridge mock (`src/renderer/src/test/mock-clean-bridge.ts`); it is typed + against `CleanApi`, so preload API changes must update it (the compiler + will insist). +- UI components (`.tsx`) are Storybook's responsibility β€” no component render + tests, and `.tsx` is excluded from coverage on purpose. +- Two vitest projects: `node` (`src/main|shared|preload`) and `renderer` + (jsdom). Run one file with `pnpm vitest run --project `. diff --git a/STATUS.html b/STATUS.html index 42322c1..7d658ea 100644 --- a/STATUS.html +++ b/STATUS.html @@ -14,7 +14,7 @@ const STATUS = { app: "TidyDisk", version: "1.0.0", - updated: "2026-07-29", + updated: "2026-08-03", phase: "πŸš€ v1.0.0 β€” TidyDisk is LIVE-ready. All three PRs merged to main (landing refresh #22, license gate + analytics #30, rename + honest copy #31); the release gate is lifted; tidydisk.app serves the new site with the €19 Polar checkout wired. The v1.0.0 tag fires the signed-release pipeline (first tidydisk-arm64.dmg). Your last clicks before selling: publish the draft release when the workflow finishes, then run the launch playbook (#27 scan screenshot, #28 assets, #29 the wave).", summary: "This session (2026-07-16): the menu bar panel is no longer a mini node_modules cleaner. It is a read-only dashboard: an aggregate 'Tracked on disk' hero (every tracked byte counted once, against the sum of your three limits) plus four click-through rows (Projects / Caches / Packages / Docker) that open that tab in the main window. Gone from the panel: the reclaimable list, the Clean stale CTA, the pnpm prune row, and the paywall. NOTE: that removes all three paywall_shown triggers from the panel, so the launcher is now the only paid-conversion surface. Built spec -> plan -> subagent-driven TDD (9 tasks, fresh implementer + independent review each); all arithmetic + gating lives in pure panelAreas.ts / launcherNav.ts so the .tsx stays thin. The hero counts the pnpm store once and is therefore smaller than the four rows added up (rows mirror their tabs so click-through never lies); Docker refreshes on open when stale, but the store re-sizes only on a scan (no du on every popover open), and the panel never computes the package inventory. 315 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and whether the launcher needs its own paywall moment now the panel has none. SHIPPED: merged to main via PR #42 (the whole Docker stack + the panel; PR #40 auto-closed) and tagged v1.1.0, which fired the signed-release pipeline to build a DRAFT dmg. Test that draft, then publish it to promote v1.1.0 to latest. Prior session (2026-07-14): finished the Docker feature on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). Two things you asked for. (1) Docker build cache no longer sits in the Docker tab: groupDockerForDisplay drops the build-cache group (items still count in the Docker header gauge, but the list only shows project/repo/orphan resources), and it now appears as a single aggregated 'Docker build cache' row in the CACHES tab next to the pnpm store. Its red Delete routes through the EXISTING requestDockerPrune('buildCache') β€” same license gate, same confirm, same toast, no new delete path β€” and the Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' (keyboard nav + Enter across them; the tab also auto-scans Docker on open when data is missing/stale, mirroring the Docker tab). (2) The deletion buttons now unmistakably read as deletions: the Docker bulk-prune buttons became 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon and red danger styling; the build-cache row's Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon variant); the per-item unused-image/volume/container trash stays hover-revealed like the node_modules rows (already danger). SAFETY UNCHANGED: presentation + a delete-button relabel; the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block, verified). Whole-branch Opus review: 1 Important fixed β€” Caches Enter indexed the full cache list while the rows were query-filtered, so Enter could fire a row scrolled off by search; a new tested selectedActionableCache() guard makes Enter a no-op unless the selected row is visible + actionable (confirm-gated the whole time, never a silent delete). Also swept em dashes out of the npm/yarn/bun placeholder copy. 293 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and push/PR (rides PR #42 or its own branch, your call). Prior session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed β†’ spec'd β†’ planned β†’ built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage β€” the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) β€” so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator β€” a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules β†’ { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apexβ†’www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 β†’ 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding β†’ €29). Produced two artifacts: TIDYDISK-LAUNCH.html β€” an interactive, on-brand launch dashboard (live priceβ†’sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) β€” and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet β€” awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", @@ -118,7 +118,8 @@ { label: "Linter + CI hardening (Biome)", detail: "Biome format+lint adopted (style-matched config), wired into CI; the previously-orphaned video/ project now typechecks in CI too. Real findings fixed; a11y rules deferred as a tracked pass", status: "done" }, { label: "Main-process hardening", detail: "IPC setSetting now validates the renderer payload (was cast as never); lifecycle teardown on quit; scanner shares its in-flight run; deduped GB/DAY constants. + validation/detect-kind/settings-store tests (48 total)", status: "done" }, { label: "Shell decomposition", detail: "Started: extracted DiskSummary from PanelApp (345β†’257 lines). Remaining β€” LauncherApp's keyboard/effect hooks β€” deferred to an interactive session (needs the running app to verify behavior)", status: "progress" }, - { label: "Manual end-to-end pass", detail: "Real tray, real deletion, real notifications β€” only you can judge this (see 'In your court')", status: "user" } + { label: "Manual end-to-end pass", detail: "Real tray, real deletion, real notifications β€” only you can judge this (see 'In your court')", status: "user" }, + { label: "Test coverage: CI-enforced ratchet + renderer hooks + high-risk main-process tests (deletion, scanner, scheduler, notifier)", detail: "BUILT on feat/test-coverage: vitest 3 (electron-vite pins vite 5, so vitest 4 is out) + @vitest/coverage-v8 split into node/renderer projects; a typed window.clean bridge mock (mock-clean-bridge.ts, compile-checked against CleanApi) backs new tests for all 11 renderer hooks; main-process backfill covers project-actions deletion safety, the scanner over a real fixture tree, the scan scheduler + threshold notifier, and small pure utils. Coverage thresholds now ratchet (auto-bump on pnpm test:coverage, CI fails below them, never hand-lowered): statements 75.19% / branches 88.52% / functions 85.96% / lines 75.19%, up from a 58.61% line baseline. 70 test files / 413 tests. UI (.tsx) intentionally untested here (Storybook's job) and E2E deferred per spec. Pending your review + merge (PR)", status: "user" } ] }, { @@ -164,7 +165,8 @@ { id: "test-external-drives", category: "testing", label: "Test external-drive scan coverage (needs a physical drive)", detail: "On feat/scan-coverage-and-liveness: pnpm dev, plug in an external drive that has a project with node_modules on it. Settings β†’ Scan locations: the drive should appear as a toggle; turn it on, rescan (⌘R), and its projects should show up in the list. Also try 'Add folder…' on a normal directory. Unplug the drive and rescan β†’ its projects drop off; replug + rescan β†’ they return. Deleting a project whose drive you just unplugged should refuse cleanly, not error." }, { id: "test-live-indicator", category: "testing", label: "Test the live/running indicator + delete guard", detail: "On feat/scan-coverage-and-liveness: pnpm dev, then start a dev server inside one of your scanned projects (e.g. pnpm dev in some app). Open the launcher (⌘O) / tray panel: that project should show a green dot with a tooltip naming the process (and :port if it listens), and its delete button should be disabled ('This app is running'). Stop the server, wait up to 45s or reopen the window β†’ the dot clears and delete re-enables. Bonus: with the server running, confirm a panel 'Clean N stale' that includes it does not delete it (main refuses)." }, { id: "test-tab-headlines", category: "testing", label: "Check the per-tab headline gauges + Docker auto-scan", detail: "On feat/docker-project-grouping: pnpm dev -> open the launcher (Cmd-O), switch tabs, and confirm the header data-viz changes per tab: Projects shows the node_modules size-vs-limit gauge (unchanged); Caches shows a gauge against the pnpm cache limit; Docker shows a gauge against the Docker limit; Packages shows the severity meter (stacked crit/high/mod/low + outdated, hover for the full breakdown). Settings -> Scanning -> Storage limits: change the pnpm cache limit and the Docker limit and watch those gauges refill. Confirm Packages shows NOTHING (not a green 'all clear') when 'Check npm for updates & advisories' is off or you are offline. Open the Docker tab cold and confirm it auto-scans; switch away and back within 5 min and confirm it does NOT rescan; Cmd-R still forces a refresh. NOTE: the main-process settings/docker changes need a full app restart (pnpm dev hot-reloads only the renderer)." }, - { id: "test-auto-update", category: "testing", label: "Manually test the auto-update flow with a signed build (PR #46 merged)", detail: "CI (typecheck/lint/tests/build + unsigned packaging) can't exercise electron-updater against a real signed release. Build a signed version + a fake higher version (pnpm release twice, bumping package.json in between), publish the higher one as a draft β†’ publish as a FULL release (NOT a prerelease β€” electron-updater ignores prereleases by default, a prerelease would look like the feature is broken), install the lower one in /Applications. Confirm the panel banner appears within ~10s of launch with the right version/size/date; its βœ• hides it and it stays hidden after a restart while Settings β†’ Updates still shows the update; clicking the banner opens the launcher on Settings β†’ Updates; Download shows live percent and Restart and install relaunches on the new version; update_installed fires on that relaunch (PostHog); and running from ~/Downloads (translocated) shows the friendly 'move to Applications' message with no crash." } + { id: "test-auto-update", category: "testing", label: "Manually test the auto-update flow with a signed build (PR #46 merged)", detail: "CI (typecheck/lint/tests/build + unsigned packaging) can't exercise electron-updater against a real signed release. Build a signed version + a fake higher version (pnpm release twice, bumping package.json in between), publish the higher one as a draft β†’ publish as a FULL release (NOT a prerelease β€” electron-updater ignores prereleases by default, a prerelease would look like the feature is broken), install the lower one in /Applications. Confirm the panel banner appears within ~10s of launch with the right version/size/date; its βœ• hides it and it stays hidden after a restart while Settings β†’ Updates still shows the update; clicking the banner opens the launcher on Settings β†’ Updates; Download shows live percent and Restart and install relaunches on the new version; update_installed fires on that relaunch (PostHog); and running from ~/Downloads (translocated) shows the friendly 'move to Applications' message with no crash." }, + { id: "review-pr-test-coverage", category: "review", label: "Review & merge PR: test coverage ratchet (feat/test-coverage)", detail: "vitest 3 + coverage-v8, node/renderer project split, a typed window.clean bridge mock, tests for all 11 renderer hooks, and main-process backfill (deletion safety, scanner, scheduler, notifier). CI now enforces a coverage ratchet: statements 75.19% / branches 88.52% / functions 85.96% / lines 75.19% (up from a 58.61% line baseline), 70 test files / 413 tests. UI (.tsx) intentionally untested (Storybook's job); E2E deferred per spec." } ], log: [ @@ -240,7 +242,9 @@ { date: "2026-07-20", text: "Merged the landing repositioning to main (PR #45) and retried the v1.1.0 release, which succeeded this time. The site work is site-only so it did not change what the v1.1.0 tag builds; merging to main also fires the Vercel production deploy, so the new 'all developer disk waste' landing (four-area section, Docker row, rebuilt hero mockup, 5 locales) is now live. The release re-run got through Apple notarization (the agreement had finally propagated), producing a signed + notarized DRAFT v1.1.0 release (tidydisk-arm64.dmg + auto-update zip/latest-mac.yml); it stays draft until you test and publish it. Your court: test + publish the draft (userAction release-110-publish-draft), the standing manual daemon QA + launcher-paywall decision, and an optional proofread of the new non-English landing copy." }, { date: "2026-07-22", text: "Built in-app auto-update end-to-end on feat/auto-update via subagent-driven TDD (11 tasks): UpdaterService wraps electron-updater (autoDownload=false, install-on-quit after a download) behind an injectable interface with a unit-tested state machine; silent checks run at launch + every 6h in packaged builds only, and the flow stays fully user-driven (Download β†’ progress β†’ Restart and install, never a forced restart). Shipped: IPC/types/settings wiring, a useUpdater hook syncing state to both renderers, a Settings β†’ Updates tab (current version, manual check, GitHub release notes), a dismissible per-version panel banner, a live deep link from the banner to Settings β†’ Updates, translocation detection (app run outside /Applications gets a friendly fix-it message instead of a crash), and analytics (update_available deduped per version, update_download_clicked, update_installed) plus post-install version detection. A whole-branch review caught real issues β€” the banner not surviving background re-checks and a stale deep-link β€” fixed in 858da18, then returned READY FOR PR. 351 tests, typecheck/lint/build green. Opened the auto-update PR; the release process is unchanged (publishing the GitHub release draft is still the go-live switch). Your court: review the PR and a manual pass with a signed build, since CI can't exercise the real update flow (userActions test-auto-update)." }, { date: "2026-07-29", text: "Full code review of the auto-update PR (#46): architecture, security and release plumbing all check out (release.yml publishes latest-mac.yml, so 'publish the draft = go-live' holds). The review found six small issues, all fixed in 671ad80 on the branch: Download click now transitions to 'downloading 0%' synchronously (double-click could previously start a second download + duplicate analytics), progress broadcasts are deduped by rounded percent (IPC spam), a repeated banner deep-link now always lands on the Updates tab (nav seq), 4xx errors are no longer misreported as 'offline' (a 404 = broken feed should surface its message), HTML entities decode exactly once, and package.json gained an explicit repository field so app-update.yml generation is deterministic. 355 tests, typecheck/lint green. One test-plan gotcha for your manual pass: publish the fake higher version as a FULL release, not a prerelease β€” electron-updater ignores prereleases by default, so a prerelease would look like the feature is broken when it isn't. Your court: PR review + the signed-build manual checklist." }, - { date: "2026-07-29", text: "You approved the review and merged PR #46: in-app auto-update is now on main (merge commit, feat/auto-update deleted) and will ship with the next tagged release. Your court unchanged: the signed-build manual checklist (userAction test-auto-update) β€” remember to publish the fake higher version as a full release, not a prerelease, since electron-updater ignores prereleases by default." } + { date: "2026-07-29", text: "You approved the review and merged PR #46: in-app auto-update is now on main (merge commit, feat/auto-update deleted) and will ship with the next tagged release. Your court unchanged: the signed-build manual checklist (userAction test-auto-update) β€” remember to publish the fake higher version as a full release, not a prerelease, since electron-updater ignores prereleases by default." }, + { date: "2026-07-29", text: "Enterprise testing landed on feat/test-coverage: vitest 3 + coverage ratchet in CI (75.2% lines), typed window.clean mock, 19 new test files (hooks + main process). PR opened." }, + { date: "2026-08-03", text: "Addressed CodeRabbit review on PR #47: one finding was a real bug β€” abbreviateHome turned /Users/mean into ~an for home /Users/me (fixed test-first with a /-boundary check); also tightened the notification mock to only accept click handlers, and replied in all four threads. PR #48's red CI was an npm-registry download timeout, not code β€” rerun came back green. Both PRs now await your review; merge #47 first, then rebase #48 (they overlap on ci.yml, package.json, pnpm-lock)." } ] }; diff --git a/package.json b/package.json index 10b9e41..f6c30f6 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "biome check .", "format": "biome format --write .", "test": "vitest run", + "test:coverage": "vitest run --coverage", "icon": "node scripts/make-icon.mjs", "demo": "node scripts/demo-data.mjs", "package": "electron-vite build && electron-builder --mac --dir", @@ -30,18 +31,21 @@ "devDependencies": { "@biomejs/biome": "2.5.0", "@remotion/cli": "^4.0.477", + "@testing-library/react": "^16.3.2", "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.2.7", "electron": "^33.2.0", "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", + "jsdom": "^30.0.0", "remotion": "^4.0.477", "typescript": "^5.7.2", "vite": "^5.4.11", - "vitest": "^2.1.8" + "vitest": "^3.2.7" }, "dependencies": { "electron-updater": "^6.8.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd029c6..ab783bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@remotion/cli': specifier: ^4.0.477 version: 4.0.477(postcss@8.5.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/node': specifier: ^22.10.0 version: 22.19.20 @@ -45,6 +48,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0)) + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.19.20)(jsdom@30.0.0)(terser@5.48.0)) electron: specifier: ^33.2.0 version: 33.4.11 @@ -54,6 +60,9 @@ importers: electron-vite: specifier: ^2.3.0 version: 2.3.0(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0)) + jsdom: + specifier: ^30.0.0 + version: 30.0.0 remotion: specifier: ^4.0.477 version: 4.0.477(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -64,14 +73,26 @@ importers: specifier: ^5.4.11 version: 5.4.21(@types/node@22.19.20)(terser@5.48.0) vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.20)(terser@5.48.0) + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.20)(jsdom@30.0.0)(terser@5.48.0) packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.0': + resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -154,6 +175,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -170,6 +195,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.5.0': resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} engines: {node: '>=14.21.3'} @@ -227,6 +256,46 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -561,6 +630,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + 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 + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -568,6 +646,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -987,6 +1069,25 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -994,6 +1095,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1009,9 +1113,15 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dom-mediacapture-transform@0.1.11': resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} @@ -1080,34 +1190,43 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/coverage-v8@3.2.7': + resolution: {integrity: sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==} + peerDependencies: + '@vitest/browser': 3.2.7 + vitest: 3.2.7 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1230,6 +1349,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1267,6 +1390,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1279,6 +1405,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1312,6 +1441,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1513,6 +1645,10 @@ packages: webpack: optional: true + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1521,6 +1657,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1530,6 +1670,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1564,6 +1707,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1583,6 +1730,9 @@ packages: os: [darwin] hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -1656,6 +1806,10 @@ packages: resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1769,6 +1923,15 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -1908,9 +2071,16 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2001,6 +2171,9 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2027,6 +2200,22 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2039,13 +2228,28 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@30.0.0: + resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2141,6 +2345,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2152,9 +2360,20 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-fetch-happen@10.2.1: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2167,6 +2386,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mediabunny@1.45.0: resolution: {integrity: sha512-oK3sMMYbucoF6LUX62L/2M9d+p9ve6KDQgL87kNfhsB0/XmTe9iRLUcgQgg9Gpgvi8Sb96zYfOUL6i17y0bdNg==} @@ -2354,6 +2576,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2366,8 +2591,8 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} @@ -2383,6 +2608,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -2436,6 +2665,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -2475,6 +2708,9 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2567,6 +2803,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -2706,6 +2946,9 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-loader@4.0.0: resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} engines: {node: '>= 18.12.0'} @@ -2724,6 +2967,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -2788,6 +3034,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2800,18 +3050,29 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -2823,9 +3084,17 @@ packages: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} @@ -2844,6 +3113,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unique-filename@2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2879,9 +3152,9 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true vite@5.4.21: @@ -2915,20 +3188,23 @@ packages: terser: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -2940,6 +3216,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -2950,6 +3230,10 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.5.0: resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} engines: {node: '>=10.13.0'} @@ -2964,6 +3248,18 @@ packages: webpack-cli: optional: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -3003,10 +3299,17 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3043,6 +3346,26 @@ snapshots: 7zip-bin@5.2.0: {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@asamuzakjp/css-color@6.0.5': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.0': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3141,6 +3464,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3170,6 +3495,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.5.0': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.0 @@ -3205,6 +3532,34 @@ snapshots: '@biomejs/cli-win32-x64@2.5.0': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.15.0 @@ -3444,6 +3799,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1': {} + '@gar/promisify@1.1.3': {} '@isaacs/cliui@8.0.2': @@ -3455,6 +3812,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3563,13 +3922,13 @@ snapshots: '@remotion/timeline-utils': 4.0.477 '@rspack/core': 1.7.11 '@rspack/plugin-react-refresh': 1.6.1(react-refresh@0.18.0) - css-loader: 7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(postcss@8.5.15)) + css-loader: 7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.15)) esbuild: 0.28.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-refresh: 0.18.0 remotion: 4.0.477(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - style-loader: 4.0.0(webpack@5.105.0(postcss@8.5.15)) + style-loader: 4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.15)) webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.15) transitivePeerDependencies: - '@minify-html/node' @@ -3919,6 +4278,27 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + '@tootallnate/once@2.0.1': {} '@tybys/wasm-util@0.10.2': @@ -3926,6 +4306,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -3954,10 +4336,17 @@ snapshots: '@types/node': 22.19.20 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/dom-mediacapture-transform@0.1.11': dependencies: '@types/dom-webcodecs': 0.1.13 @@ -4041,45 +4430,66 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@2.1.9': + '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.19.20)(jsdom@30.0.0)(terser@5.48.0))': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.20)(jsdom@30.0.0)(terser@5.48.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 chai: 5.3.3 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0))': + '@vitest/mocker@3.2.7(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 5.4.21(@types/node@22.19.20)(terser@5.48.0) - '@vitest/pretty-format@2.1.9': + '@vitest/pretty-format@3.2.7': dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/runner@2.1.9': + '@vitest/runner@3.2.7': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 - '@vitest/snapshot@2.1.9': + '@vitest/snapshot@3.2.7': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.7 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': + '@vitest/spy@3.2.7': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.4 - '@vitest/utils@2.1.9': + '@vitest/utils@3.2.7': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.7 loupe: 3.2.1 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -4223,6 +4633,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} app-builder-bin@5.0.0-alpha.10: {} @@ -4312,6 +4724,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + assert-plus@1.0.0: optional: true @@ -4321,6 +4737,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astral-regex@2.0.0: optional: true @@ -4340,6 +4762,10 @@ snapshots: baseline-browser-mapping@2.10.34: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -4572,7 +4998,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(postcss@8.5.15)): + css-loader@7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.15)): dependencies: icss-utils: 5.1.0(postcss@8.5.15) postcss: 8.5.15 @@ -4586,14 +5012,28 @@ snapshots: '@rspack/core': 1.7.11 webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.15) + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4626,6 +5066,8 @@ snapshots: delegates@1.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node@2.1.0: @@ -4663,6 +5105,8 @@ snapshots: verror: 1.10.1 optional: true + dom-accessibility-api@0.5.16: {} + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -4776,6 +5220,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + env-paths@2.2.1: {} err-code@2.0.3: {} @@ -4926,6 +5372,10 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -5107,8 +5557,16 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-entities@2.6.0: {} + html-escaper@2.0.2: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@5.0.0: @@ -5194,6 +5652,8 @@ snapshots: is-lambda@1.0.1: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-unicode-supported@0.1.0: {} @@ -5210,6 +5670,27 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -5228,12 +5709,42 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.2.0: dependencies: argparse: 2.0.1 + jsdom@30.0.0: + dependencies: + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.0 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.9.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -5306,6 +5817,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5316,10 +5829,22 @@ snapshots: lru-cache@7.18.3: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + make-fetch-happen@10.2.1: dependencies: agentkeepalive: 4.6.0 @@ -5349,6 +5874,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mediabunny@1.45.0: dependencies: '@types/dom-mediacapture-transform': 0.1.11 @@ -5531,6 +6058,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5540,7 +6071,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - pathe@1.1.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -5550,6 +6081,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.5: {} + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 @@ -5596,6 +6129,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-nextick-args@2.0.1: {} progress@2.0.3: {} @@ -5627,6 +6166,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react-refresh@0.18.0: {} @@ -5752,6 +6293,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -5881,7 +6426,11 @@ snapshots: strip-final-newline@2.0.0: {} - style-loader@4.0.0(webpack@5.105.0(postcss@8.5.15)): + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-loader@4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.15)): dependencies: webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.15) @@ -5899,6 +6448,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tapable@2.3.3: {} tar-stream@2.2.0: @@ -5941,6 +6492,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + tiny-invariant@1.3.3: {} tiny-typed-emitter@2.1.0: {} @@ -5949,11 +6506,22 @@ snapshots: tinyexec@0.3.2: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@1.1.1: {} - tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@7.4.9: {} - tinyspy@3.0.2: {} + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 tmp-promise@3.0.3: dependencies: @@ -5963,10 +6531,18 @@ snapshots: to-fast-properties@2.0.0: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 @@ -5980,6 +6556,8 @@ snapshots: undici-types@6.21.0: {} + undici@8.9.0: {} + unique-filename@2.0.1: dependencies: unique-slug: 3.0.0 @@ -6013,12 +6591,12 @@ snapshots: extsprintf: 1.4.1 optional: true - vite-node@2.1.9(@types/node@22.19.20)(terser@5.48.0): + vite-node@3.2.4(@types/node@22.19.20)(terser@5.48.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 - pathe: 1.1.2 + pathe: 2.0.3 vite: 5.4.21(@types/node@22.19.20)(terser@5.48.0) transitivePeerDependencies: - '@types/node' @@ -6041,30 +6619,35 @@ snapshots: fsevents: 2.3.3 terser: 5.48.0 - vitest@2.1.9(@types/node@22.19.20)(terser@5.48.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.19.20)(jsdom@30.0.0)(terser@5.48.0): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@5.4.21(@types/node@22.19.20)(terser@5.48.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 + picomatch: 4.0.5 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 + tinyglobby: 0.2.17 tinypool: 1.1.1 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 vite: 5.4.21(@types/node@22.19.20)(terser@5.48.0) - vite-node: 2.1.9(@types/node@22.19.20)(terser@5.48.0) + vite-node: 3.2.4(@types/node@22.19.20)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.13 '@types/node': 22.19.20 + jsdom: 30.0.0 transitivePeerDependencies: - less - lightningcss @@ -6076,6 +6659,10 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 @@ -6086,6 +6673,8 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.5.0: {} webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.15): @@ -6129,6 +6718,24 @@ snapshots: - postcss - uglify-js + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -6164,8 +6771,12 @@ snapshots: ws@8.20.1: {} + xml-name-validator@5.0.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/main/actions/project-actions.test.ts b/src/main/actions/project-actions.test.ts new file mode 100644 index 0000000..dac0fc4 --- /dev/null +++ b/src/main/actions/project-actions.test.ts @@ -0,0 +1,105 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Project } from '@shared/project.types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + trashItem: vi.fn(async (_p: string) => {}), + showItemInFolder: vi.fn((_p: string) => {}), + openPath: vi.fn(async (_p: string) => ''), + execFileFails: { value: false }, + execFileCalls: [] as Array<{ cmd: string; args: string[] }>, +})) + +vi.mock('electron', () => ({ + shell: { + trashItem: mocks.trashItem, + showItemInFolder: mocks.showItemInFolder, + openPath: mocks.openPath, + }, +})) + +vi.mock('node:child_process', () => ({ + execFile: (cmd: string, args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { + mocks.execFileCalls.push({ cmd, args }) + cb(mocks.execFileFails.value ? new Error('open failed') : null, '', '') + }, +})) + +import { deleteNodeModules, guardExists, openProject, revealInFinder } from './project-actions' + +function project(over: Partial = {}): Project { + return { + id: 'p1', + name: 'demo', + path: '~/code/demo', + absPath: '/Users/me/code/demo', + kind: 'node', + size: 4096, + lastUsed: 0, + ...over, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.execFileFails.value = false + mocks.execFileCalls.length = 0 +}) + +describe('deleteNodeModules', () => { + it('trashes exactly /node_modules β€” never anything else β€” and reports freed bytes', async () => { + const p = project() + const freed = await deleteNodeModules(p) + expect(mocks.trashItem).toHaveBeenCalledTimes(1) + expect(mocks.trashItem).toHaveBeenCalledWith('/Users/me/code/demo/node_modules') + expect(freed).toBe(4096) + }) + + it('propagates trash failures instead of reporting success', async () => { + mocks.trashItem.mockRejectedValueOnce(new Error('EPERM')) + await expect(deleteNodeModules(project())).rejects.toThrow('EPERM') + }) +}) + +describe('guardExists', () => { + let dir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'tidy-guard-')) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('passes (null) when node_modules exists on disk', async () => { + const nm = join(dir, 'node_modules') + await mkdir(nm) + expect(guardExists(nm)).toBeNull() + }) + + it('blocks as unmounted when the path vanished since the scan', () => { + expect(guardExists(join(dir, 'node_modules'))).toEqual({ freed: 0, blocked: 'unmounted' }) + }) +}) + +describe('revealInFinder', () => { + it('reveals the node_modules folder itself', () => { + revealInFinder(project()) + expect(mocks.showItemInFolder).toHaveBeenCalledWith('/Users/me/code/demo/node_modules') + }) +}) + +describe('openProject', () => { + it('opens in VS Code when available', async () => { + await openProject(project()) + expect(mocks.openPath).not.toHaveBeenCalled() + expect(mocks.execFileCalls).toEqual([{ cmd: 'open', args: ['-a', 'Visual Studio Code', '/Users/me/code/demo'] }]) + }) + + it('falls back to Finder when the editor launch fails', async () => { + mocks.execFileFails.value = true + await openProject(project()) + expect(mocks.openPath).toHaveBeenCalledWith('/Users/me/code/demo') + }) +}) diff --git a/src/main/lib/abbreviate-home.test.ts b/src/main/lib/abbreviate-home.test.ts new file mode 100644 index 0000000..79d7bc9 --- /dev/null +++ b/src/main/lib/abbreviate-home.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateHome } from './abbreviate-home' + +describe('abbreviateHome', () => { + it('replaces the home prefix with ~', () => { + expect(abbreviateHome('/Users/me/code/app', '/Users/me')).toBe('~/code/app') + }) + it('leaves non-home paths untouched', () => { + expect(abbreviateHome('/Volumes/Ext/code', '/Users/me')).toBe('/Volumes/Ext/code') + }) + it('abbreviates the home directory itself to ~', () => { + expect(abbreviateHome('/Users/me', '/Users/me')).toBe('~') + }) + it('leaves sibling paths sharing the home prefix untouched', () => { + expect(abbreviateHome('/Users/mean', '/Users/me')).toBe('/Users/mean') + }) +}) diff --git a/src/main/lib/abbreviate-home.ts b/src/main/lib/abbreviate-home.ts index abf02c6..6b6d1e7 100644 --- a/src/main/lib/abbreviate-home.ts +++ b/src/main/lib/abbreviate-home.ts @@ -2,5 +2,5 @@ import { homedir } from 'node:os' /** Replaces the home-directory prefix with ~ for display. */ export function abbreviateHome(path: string, home = homedir()): string { - return path.startsWith(home) ? `~${path.slice(home.length)}` : path + return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path } diff --git a/src/main/notifications/threshold-notifier.test.ts b/src/main/notifications/threshold-notifier.test.ts new file mode 100644 index 0000000..066e2f4 --- /dev/null +++ b/src/main/notifications/threshold-notifier.test.ts @@ -0,0 +1,67 @@ +import { GB } from '@shared/units.constants' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + shown: [] as Array<{ title: string; body: string }>, + clicks: [] as Array<() => void>, + supported: { value: true }, +})) + +vi.mock('electron', () => ({ + Notification: class { + static isSupported = (): boolean => mocks.supported.value + constructor(private opts: { title: string; body: string }) {} + on(event: string, fn: () => void): void { + if (event === 'click') mocks.clicks.push(fn) + } + show(): void { + mocks.shown.push(this.opts) + } + }, +})) + +import { ThresholdNotifier } from './threshold-notifier' + +beforeEach(() => { + mocks.shown.length = 0 + mocks.clicks.length = 0 + mocks.supported.value = true +}) + +describe('ThresholdNotifier', () => { + it('fires once when usage crosses the threshold, not while it stays over', () => { + const n = new ThresholdNotifier(() => {}) + n.check(4 * GB, 5, true) + expect(mocks.shown).toHaveLength(0) + n.check(6 * GB, 5, true) + expect(mocks.shown).toHaveLength(1) + expect(mocks.shown[0].body).toContain('6.00 GB') + n.check(7 * GB, 5, true) + expect(mocks.shown).toHaveLength(1) + }) + + it('re-arms after dropping back below the threshold', () => { + const n = new ThresholdNotifier(() => {}) + n.check(6 * GB, 5, true) + n.check(4 * GB, 5, true) + n.check(6 * GB, 5, true) + expect(mocks.shown).toHaveLength(2) + }) + + it('never fires when disabled or unsupported', () => { + const n = new ThresholdNotifier(() => {}) + n.check(6 * GB, 5, false) + mocks.supported.value = false + n.check(4 * GB, 5, true) // reset below + n.check(6 * GB, 5, true) + expect(mocks.shown).toHaveLength(0) + }) + + it('clicking the notification opens the app', () => { + const onOpen = vi.fn() + const n = new ThresholdNotifier(onOpen) + n.check(6 * GB, 5, true) + mocks.clicks[0]() + expect(onOpen).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/scanner/scanner.test.ts b/src/main/scanner/scanner.test.ts new file mode 100644 index 0000000..51f300c --- /dev/null +++ b/src/main/scanner/scanner.test.ts @@ -0,0 +1,71 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ScanProgress } from '@shared/project.types' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Scanner } from './scanner' + +let root: string + +/** Creates //node_modules with one 2KB file so sizes are non-zero. */ +async function makeApp(rel: string, name: string): Promise { + const dir = join(root, rel) + await mkdir(join(dir, 'node_modules', 'pkg'), { recursive: true }) + await writeFile(join(dir, 'package.json'), JSON.stringify({ name })) + await writeFile(join(dir, 'node_modules', 'pkg', 'index.js'), 'x'.repeat(2048)) + return dir +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'tidy-scan-')) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('Scanner', () => { + it('finds projects, builds entries, and sorts by lastUsed ascending', async () => { + const aDir = await makeApp('app-a', 'app-a') + await makeApp('nested/app-b', 'app-b') + const projects = await new Scanner().scan([root]) + expect(projects.map((p) => p.name).sort()).toEqual(['app-a', 'app-b']) + const a = projects.find((p) => p.name === 'app-a') + expect(a?.absPath).toBe(aDir) + expect(a?.size).toBeGreaterThan(0) + expect(projects.map((p) => p.lastUsed)).toEqual([...projects.map((p) => p.lastUsed)].sort((x, y) => x - y)) + }) + + it('never descends into node_modules or dot-directories', async () => { + await makeApp('app-a', 'app-a') + // a nested install inside app-a's node_modules must NOT become a project + await mkdir(join(root, 'app-a', 'node_modules', 'dep', 'node_modules'), { recursive: true }) + // a project hidden in a dot-directory must NOT be found + await makeApp('.cache/secret-app', 'secret-app') + const projects = await new Scanner().scan([root]) + expect(projects.map((p) => p.name)).toEqual(['app-a']) + }) + + it('shares an in-flight scan between concurrent callers', async () => { + await makeApp('app-a', 'app-a') + const scanner = new Scanner() + const p1 = scanner.scan([root]) + const p2 = scanner.scan([root]) + expect(p1).toBe(p2) + expect(scanner.isScanning).toBe(true) + await p1 + expect(scanner.isScanning).toBe(false) + }) + + it('emits a final done progress event', async () => { + await makeApp('app-a', 'app-a') + const events: ScanProgress[] = [] + await new Scanner().scan([root], (p) => events.push(p)) + expect(events.at(-1)).toMatchObject({ done: true }) + expect(events.at(-1)?.foldersChecked).toBeGreaterThan(0) + }) + + it('survives unreadable roots without throwing', async () => { + const projects = await new Scanner().scan([join(root, 'does-not-exist')]) + expect(projects).toEqual([]) + }) +}) diff --git a/src/main/scheduler/scan-scheduler.test.ts b/src/main/scheduler/scan-scheduler.test.ts new file mode 100644 index 0000000..4804512 --- /dev/null +++ b/src/main/scheduler/scan-scheduler.test.ts @@ -0,0 +1,40 @@ +import { SCAN_INTERVAL_MS } from '@shared/settings.constants' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ScanScheduler } from './scan-scheduler' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('ScanScheduler', () => { + it('re-runs the scan on the configured interval', () => { + const run = vi.fn() + const s = new ScanScheduler(run) + s.apply('6h') + vi.advanceTimersByTime(SCAN_INTERVAL_MS['6h']) + expect(run).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(SCAN_INTERVAL_MS['6h']) + expect(run).toHaveBeenCalledTimes(2) + s.stop() + }) + + it('manual disables scheduling entirely', () => { + const run = vi.fn() + const s = new ScanScheduler(run) + s.apply('6h') + s.apply('manual') + vi.advanceTimersByTime(SCAN_INTERVAL_MS.weekly) + expect(run).not.toHaveBeenCalled() + }) + + it('re-applying replaces the previous timer instead of stacking', () => { + const run = vi.fn() + const s = new ScanScheduler(run) + s.apply('daily') + s.apply('daily') + vi.advanceTimersByTime(SCAN_INTERVAL_MS.daily) + expect(run).toHaveBeenCalledTimes(1) + s.stop() + vi.advanceTimersByTime(SCAN_INTERVAL_MS.daily) + expect(run).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/tray/png-encode.test.ts b/src/main/tray/png-encode.test.ts new file mode 100644 index 0000000..5a6a1b7 --- /dev/null +++ b/src/main/tray/png-encode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { encodePng } from './png-encode' + +describe('encodePng', () => { + it('produces a valid PNG header with the right dimensions', () => { + const w = 3 + const h = 2 + const png = encodePng(Buffer.alloc(w * h * 4, 0xff), w, h) + // PNG signature + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + // IHDR chunk starts at byte 8: 4-byte length, 'IHDR', then width/height + expect(png.subarray(12, 16).toString('ascii')).toBe('IHDR') + expect(png.readUInt32BE(16)).toBe(w) + expect(png.readUInt32BE(20)).toBe(h) + // 8-bit RGBA + expect(png[24]).toBe(8) + expect(png[25]).toBe(6) + }) +}) diff --git a/src/main/windows/window-utils.test.ts b/src/main/windows/window-utils.test.ts new file mode 100644 index 0000000..f895e38 --- /dev/null +++ b/src/main/windows/window-utils.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// `is.dev` is computed at module load, so each case stubs env then re-imports. +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +async function loadIsDev(): Promise { + vi.resetModules() + const { is } = await import('./window-utils') + return is.dev +} + +describe('is.dev', () => { + it('true when the renderer dev-server URL is set', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173') + vi.stubEnv('NODE_ENV', 'production') + expect(await loadIsDev()).toBe(true) + }) + it('true in NODE_ENV=development', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', '') + vi.stubEnv('NODE_ENV', 'development') + expect(await loadIsDev()).toBe(true) + }) + it('false in a packaged run', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', '') + vi.stubEnv('NODE_ENV', 'production') + expect(await loadIsDev()).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useAutoHeight.test.ts b/src/renderer/src/hooks/useAutoHeight.test.ts new file mode 100644 index 0000000..9ff93a4 --- /dev/null +++ b/src/renderer/src/hooks/useAutoHeight.test.ts @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../test/mock-clean-bridge' +import { useAutoHeight } from './useAutoHeight' + +const observers: Array<{ observe: ReturnType; disconnect: ReturnType }> = [] + +beforeEach(() => { + installMockClean() + observers.length = 0 + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + constructor() { + observers.push(this) + } + }, + ) +}) + +describe('useAutoHeight', () => { + it('syncs the window height to the element layout box and observes resizes', () => { + const el = document.createElement('div') + Object.defineProperty(el, 'offsetHeight', { value: 240 }) + const { unmount } = renderHook(() => useAutoHeight({ current: el })) + expect(vi.mocked(window.clean.setWindowHeight)).toHaveBeenCalledWith(240) + expect(observers).toHaveLength(1) + expect(observers[0].observe).toHaveBeenCalledWith(el) + unmount() + expect(observers[0].disconnect).toHaveBeenCalledOnce() + }) + + it('does nothing when the ref is empty', () => { + renderHook(() => useAutoHeight({ current: null })) + expect(vi.mocked(window.clean.setWindowHeight)).not.toHaveBeenCalled() + expect(observers).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/hooks/useDocker.test.ts b/src/renderer/src/hooks/useDocker.test.ts new file mode 100644 index 0000000..4e4a32e --- /dev/null +++ b/src/renderer/src/hooks/useDocker.test.ts @@ -0,0 +1,74 @@ +import type { DockerActionResult, DockerInfo } from '@shared/docker.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../test/mock-clean-bridge' +import { useDocker } from './useDocker' + +const info = (checkedAt: number): DockerInfo => ({ + available: true, + checkedAt, + totals: [], + items: [], + projects: [], +}) + +beforeEach(() => { + installMockClean() +}) + +describe('useDocker', () => { + it('loads once on mount and clears loading', async () => { + vi.mocked(window.clean.getDocker).mockResolvedValue(info(1)) + const { result } = renderHook(() => useDocker()) + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.info?.checkedAt).toBe(1) + expect(vi.mocked(window.clean.getDocker)).toHaveBeenCalledWith() + }) + + it('remove() sets busyId for the item, refreshes with force, then clears', async () => { + vi.mocked(window.clean.getDocker).mockResolvedValue(info(1)) + const { result } = renderHook(() => useDocker()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + let resolveRemove!: (r: DockerActionResult) => void + vi.mocked(window.clean.removeDockerItem).mockReturnValue( + new Promise((r) => { + resolveRemove = r + }), + ) + vi.mocked(window.clean.getDocker).mockResolvedValue(info(2)) + act(() => { + void result.current.remove('image', 'img1') + }) + expect(result.current.busyId).toBe('img1') + await act(async () => resolveRemove({ ok: true, freedBytes: 10 })) + await waitFor(() => expect(result.current.busyId).toBeNull()) + expect(vi.mocked(window.clean.getDocker)).toHaveBeenLastCalledWith(true) + expect(result.current.info?.checkedAt).toBe(2) + }) + + it('prune() marks busy as prune: and clears even when the call rejects', async () => { + const { result } = renderHook(() => useDocker()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + let rejectPrune!: (e: Error) => void + vi.mocked(window.clean.pruneDocker).mockReturnValue( + new Promise((_, reject) => { + rejectPrune = reject + }), + ) + let pruneCall!: Promise + act(() => { + pruneCall = result.current.prune('buildCache') + pruneCall.catch(() => {}) + }) + expect(result.current.busyId).toBe('prune:buildCache') + + await act(async () => { + rejectPrune(new Error('boom')) + await expect(pruneCall).rejects.toThrow('boom') + }) + expect(result.current.busyId).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/useLicense.test.ts b/src/renderer/src/hooks/useLicense.test.ts new file mode 100644 index 0000000..ea5109a --- /dev/null +++ b/src/renderer/src/hooks/useLicense.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean, type MockClean } from '../test/mock-clean-bridge' +import { useLicense } from './useLicense' + +let bridge: MockClean +beforeEach(() => { + bridge = installMockClean() +}) + +describe('useLicense', () => { + it('starts free, loads the stored state, follows change broadcasts', async () => { + vi.mocked(window.clean.getLicense).mockResolvedValue({ pro: true, email: 'a@b.c' }) + const { result } = renderHook(() => useLicense()) + expect(result.current.license).toEqual({ pro: false }) + await waitFor(() => expect(result.current.license.pro).toBe(true)) + + act(() => bridge.emit.licenseChanged({ pro: false, needsReverify: true })) + expect(result.current.license.needsReverify).toBe(true) + }) + + it('activate() applies the returned state on success and not on failure', async () => { + const { result } = renderHook(() => useLicense()) + vi.mocked(window.clean.activateLicense).mockResolvedValue({ ok: false, reason: 'network' }) + await act(async () => { + const r = await result.current.activate('bad-key') + expect(r.ok).toBe(false) + }) + expect(result.current.license.pro).toBe(false) + + vi.mocked(window.clean.activateLicense).mockResolvedValue({ ok: true, state: { pro: true } }) + await act(async () => { + await result.current.activate('good-key') + }) + expect(result.current.license.pro).toBe(true) + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useLicense()) + expect(bridge.listeners.license).toBe(1) + unmount() + expect(bridge.listeners.license).toBe(0) + }) +}) diff --git a/src/renderer/src/hooks/useLiveProjects/useLiveProjects.test.ts b/src/renderer/src/hooks/useLiveProjects/useLiveProjects.test.ts new file mode 100644 index 0000000..50d19f0 --- /dev/null +++ b/src/renderer/src/hooks/useLiveProjects/useLiveProjects.test.ts @@ -0,0 +1,38 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../../test/mock-clean-bridge' +import { useLiveProjects } from './useLiveProjects' + +beforeEach(() => { + installMockClean() + vi.useFakeTimers() +}) +afterEach(() => vi.useRealTimers()) + +describe('useLiveProjects', () => { + it('fetches immediately, then re-polls every 45s', async () => { + vi.mocked(window.clean.getLiveProjects).mockResolvedValue({ a: { pid: 1, command: 'node' } }) + const { result } = renderHook(() => useLiveProjects()) + await act(async () => {}) // flush the initial tick's promise + expect(result.current).toEqual({ a: { pid: 1, command: 'node' } }) + + vi.mocked(window.clean.getLiveProjects).mockResolvedValue({}) + await act(async () => { + vi.advanceTimersByTime(45_000) + }) + expect(result.current).toEqual({}) + expect(vi.mocked(window.clean.getLiveProjects)).toHaveBeenCalledTimes(2) + }) + + it('stops polling after unmount', async () => { + const { unmount } = renderHook(() => useLiveProjects()) + await act(async () => {}) + unmount() + await act(async () => { + vi.advanceTimersByTime(45_000 * 3) + }) + // Only the interval teardown is observable; React 18 makes any late + // post-unmount setLive a silent no-op, so the active guard can't be asserted. + expect(vi.mocked(window.clean.getLiveProjects)).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/hooks/usePackages.test.ts b/src/renderer/src/hooks/usePackages.test.ts new file mode 100644 index 0000000..bb1e982 --- /dev/null +++ b/src/renderer/src/hooks/usePackages.test.ts @@ -0,0 +1,38 @@ +import type { PackageInventory } from '@shared/package.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../test/mock-clean-bridge' +import { usePackages } from './usePackages' + +const inv = (computedAt: number): PackageInventory => ({ packages: [], computedAt, projectCount: 0 }) + +beforeEach(() => { + installMockClean() +}) + +describe('usePackages', () => { + it('shows a cached inventory immediately when main has one', async () => { + vi.mocked(window.clean.getPackages).mockResolvedValue(inv(1)) + const { result } = renderHook(() => usePackages()) + await waitFor(() => expect(result.current.inventory?.computedAt).toBe(1)) + expect(vi.mocked(window.clean.computePackages)).not.toHaveBeenCalled() + }) + + it('ensure() computes exactly once, refresh() forces', async () => { + vi.mocked(window.clean.computePackages).mockResolvedValue(inv(2)) + const { result } = renderHook(() => usePackages()) + await act(async () => { + result.current.ensure() + result.current.ensure() + }) + expect(vi.mocked(window.clean.computePackages)).toHaveBeenCalledTimes(1) + expect(vi.mocked(window.clean.computePackages)).toHaveBeenCalledWith(false) + expect(result.current.inventory?.computedAt).toBe(2) + + vi.mocked(window.clean.computePackages).mockResolvedValue(inv(3)) + await act(async () => result.current.refresh()) + expect(vi.mocked(window.clean.computePackages)).toHaveBeenLastCalledWith(true) + expect(result.current.inventory?.computedAt).toBe(3) + expect(result.current.computing).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/usePackagesTab.test.ts b/src/renderer/src/hooks/usePackagesTab.test.ts new file mode 100644 index 0000000..13bcfbd --- /dev/null +++ b/src/renderer/src/hooks/usePackagesTab.test.ts @@ -0,0 +1,79 @@ +import type { PackageEntry, PackageInventory } from '@shared/package.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../test/mock-clean-bridge' +import { usePackagesTab } from './usePackagesTab' + +function entry(name: string, over: Partial = {}): PackageEntry { + return { name, usages: [], projectCount: 1, versions: ['1.0.0'], multipleVersions: false, ...over } +} + +const inventory: PackageInventory = { + packages: [ + entry('react', { projectCount: 5, size: 10 }), + entry('lodash', { projectCount: 2, size: 300, outdated: true }), + entry('axios', { + projectCount: 3, + size: 50, + advisory: { severity: 'high', title: 'ssrf', vulnerableVersions: '<1' }, + }), + ], + computedAt: 1, + projectCount: 5, +} + +beforeEach(() => { + installMockClean() + vi.mocked(window.clean.getPackages).mockResolvedValue(inventory) + vi.mocked(window.clean.computePackages).mockResolvedValue(inventory) +}) + +function mount(query = '', active = true) { + return renderHook(({ q, a }) => usePackagesTab(q, a), { initialProps: { q: query, a: active } }) +} + +describe('usePackagesTab', () => { + it('computes on first activation only', async () => { + const { rerender } = mount('', false) + expect(vi.mocked(window.clean.computePackages)).not.toHaveBeenCalled() + rerender({ q: '', a: true }) + await waitFor(() => expect(vi.mocked(window.clean.computePackages)).toHaveBeenCalledTimes(1)) + rerender({ q: '', a: false }) + rerender({ q: '', a: true }) + expect(vi.mocked(window.clean.computePackages)).toHaveBeenCalledTimes(1) + }) + + it('default sort is by projectCount; size/name/updates re-sort', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.filtered).toHaveLength(3)) + expect(result.current.filtered.map((p) => p.name)).toEqual(['react', 'axios', 'lodash']) + act(() => result.current.setSortBy('size')) + expect(result.current.filtered.map((p) => p.name)).toEqual(['lodash', 'axios', 'react']) + act(() => result.current.setSortBy('name')) + expect(result.current.filtered.map((p) => p.name)).toEqual(['axios', 'lodash', 'react']) + act(() => result.current.setSortBy('updates')) + // advisory (2) beats outdated (1) beats clean (0) + expect(result.current.filtered.map((p) => p.name)).toEqual(['axios', 'lodash', 'react']) + }) + + it('filters by query and a query change collapses the open panel', async () => { + const { result, rerender } = mount() + await waitFor(() => expect(result.current.filtered).toHaveLength(3)) + act(() => result.current.toggleExpand('react')) + expect(result.current.expandedName).toBe('react') + rerender({ q: 'lo', a: true }) + expect(result.current.expandedName).toBeNull() + expect(result.current.filtered.map((p) => p.name)).toEqual(['lodash']) + }) + + it('toggleExpand toggles and collapse clears', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.filtered).toHaveLength(3)) + act(() => result.current.toggleExpand('axios')) + act(() => result.current.toggleExpand('axios')) + expect(result.current.expandedName).toBeNull() + act(() => result.current.toggleExpand('axios')) + act(() => result.current.collapse()) + expect(result.current.expandedName).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/usePackagesTab.ts b/src/renderer/src/hooks/usePackagesTab.ts index 2bfa6e0..5723b32 100644 --- a/src/renderer/src/hooks/usePackagesTab.ts +++ b/src/renderer/src/hooks/usePackagesTab.ts @@ -35,7 +35,6 @@ export function usePackagesTab(query: string, active: boolean): PackagesTab { }, [active, ensure]) // A new search collapses any open detail panel. - // biome-ignore lint/correctness/useExhaustiveDependencies: collapse is the intended effect of a query change useEffect(() => { setExpandedName(null) }, [query]) diff --git a/src/renderer/src/hooks/usePnpmStore.test.ts b/src/renderer/src/hooks/usePnpmStore.test.ts new file mode 100644 index 0000000..410dd38 --- /dev/null +++ b/src/renderer/src/hooks/usePnpmStore.test.ts @@ -0,0 +1,62 @@ +import type { PnpmStoreInfo } from '@shared/pnpm-store.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean } from '../test/mock-clean-bridge' +import { usePnpmStore } from './usePnpmStore' + +const store = (sizeBytes: number): PnpmStoreInfo => ({ + available: true, + path: '/store', + displayPath: '~/store', + sizeBytes, + checkedAt: 1, + source: 'pnpm', + canPrune: true, +}) + +beforeEach(() => { + installMockClean() +}) + +describe('usePnpmStore', () => { + it('loads on mount and clears loading', async () => { + vi.mocked(window.clean.getPnpmStore).mockResolvedValue(store(100)) + const { result } = renderHook(() => usePnpmStore()) + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.store?.sizeBytes).toBe(100) + }) + + it('prune() re-reads the store and reports the result', async () => { + vi.mocked(window.clean.getPnpmStore).mockResolvedValue(store(100)) + const { result } = renderHook(() => usePnpmStore()) + await waitFor(() => expect(result.current.loading).toBe(false)) + vi.mocked(window.clean.prunePnpmStore).mockResolvedValue({ ok: true, freedBytes: 40 }) + vi.mocked(window.clean.getPnpmStore).mockResolvedValue(store(60)) + await act(async () => { + const r = await result.current.prune() + expect(r).toEqual({ ok: true, freedBytes: 40 }) + }) + expect(result.current.store?.sizeBytes).toBe(60) + expect(result.current.pruning).toBe(false) + }) + + it('prune() swallows failures and returns null', async () => { + const { result } = renderHook(() => usePnpmStore()) + await waitFor(() => expect(result.current.loading).toBe(false)) + vi.mocked(window.clean.prunePnpmStore).mockRejectedValue(new Error('no pnpm')) + await act(async () => { + expect(await result.current.prune()).toBeNull() + }) + expect(result.current.pruning).toBe(false) + }) + + it('refresh() forces a re-measure', async () => { + const { result } = renderHook(() => usePnpmStore()) + await waitFor(() => expect(result.current.loading).toBe(false)) + vi.mocked(window.clean.getPnpmStore).mockResolvedValue(store(7)) + await act(async () => result.current.refresh()) + expect(vi.mocked(window.clean.getPnpmStore)).toHaveBeenLastCalledWith(true) + expect(result.current.store?.sizeBytes).toBe(7) + }) +}) diff --git a/src/renderer/src/hooks/useProjects.test.ts b/src/renderer/src/hooks/useProjects.test.ts new file mode 100644 index 0000000..1328723 --- /dev/null +++ b/src/renderer/src/hooks/useProjects.test.ts @@ -0,0 +1,41 @@ +import type { Project } from '@shared/project.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean, type MockClean, makeProject } from '../test/mock-clean-bridge' +import { useProjects } from './useProjects' + +let bridge: MockClean +beforeEach(() => { + bridge = installMockClean() +}) + +describe('useProjects', () => { + it('loads the initial inventory then follows change events', async () => { + const first = [makeProject({ id: 'a' })] + vi.mocked(window.clean.getProjects).mockResolvedValue(first) + const { result } = renderHook(() => useProjects()) + expect(result.current).toEqual([]) + await waitFor(() => expect(result.current).toEqual(first)) + + const next = [makeProject({ id: 'b' })] + act(() => bridge.emit.projectsChanged(next)) + expect(result.current).toEqual(next) + }) + + it('unsubscribes on unmount; late fetch resolution is safe', async () => { + let resolve!: (p: Project[]) => void + vi.mocked(window.clean.getProjects).mockReturnValue( + new Promise((r) => { + resolve = r + }), + ) + const { result, unmount } = renderHook(() => useProjects()) + expect(bridge.listeners.projects).toBe(1) + unmount() + expect(bridge.listeners.projects).toBe(0) + // React 18 makes post-unmount setState a no-op, so the alive guard has no + // observable effect to assert β€” this only proves late resolution is safe. + await act(async () => resolve([makeProject()])) + expect(result.current).toEqual([]) + }) +}) diff --git a/src/renderer/src/hooks/useScanProgress.test.ts b/src/renderer/src/hooks/useScanProgress.test.ts new file mode 100644 index 0000000..cb22256 --- /dev/null +++ b/src/renderer/src/hooks/useScanProgress.test.ts @@ -0,0 +1,27 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { installMockClean, type MockClean } from '../test/mock-clean-bridge' +import { useScanProgress } from './useScanProgress' + +let bridge: MockClean +beforeEach(() => { + bridge = installMockClean() +}) + +describe('useScanProgress', () => { + it('is null until an event arrives, then mirrors the latest event', () => { + const { result } = renderHook(() => useScanProgress()) + expect(result.current).toBeNull() + act(() => bridge.emit.scanProgress({ foldersChecked: 5, currentPath: '/a', done: false })) + expect(result.current?.foldersChecked).toBe(5) + act(() => bridge.emit.scanProgress({ foldersChecked: 9, currentPath: '', done: true })) + expect(result.current?.done).toBe(true) + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useScanProgress()) + expect(bridge.listeners.scanProgress).toBe(1) + unmount() + expect(bridge.listeners.scanProgress).toBe(0) + }) +}) diff --git a/src/renderer/src/hooks/useSettings.test.ts b/src/renderer/src/hooks/useSettings.test.ts new file mode 100644 index 0000000..134769b --- /dev/null +++ b/src/renderer/src/hooks/useSettings.test.ts @@ -0,0 +1,41 @@ +import { DEFAULT_SETTINGS } from '@shared/settings.constants' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean, type MockClean } from '../test/mock-clean-bridge' +import { useSettings } from './useSettings' + +let bridge: MockClean +beforeEach(() => { + bridge = installMockClean() +}) + +describe('useSettings', () => { + it('serves defaults until the first fetch resolves, then flips loaded', async () => { + vi.mocked(window.clean.getSettings).mockResolvedValue({ ...DEFAULT_SETTINGS, thresholdGB: 42 }) + const { result } = renderHook(() => useSettings()) + expect(result.current[0]).toEqual(DEFAULT_SETTINGS) + expect(result.current[2]).toBe(false) + await waitFor(() => expect(result.current[2]).toBe(true)) + expect(result.current[0].thresholdGB).toBe(42) + }) + + it('setSetting applies optimistically and forwards to the bridge', async () => { + const { result } = renderHook(() => useSettings()) + await waitFor(() => expect(result.current[2]).toBe(true)) + act(() => { + void result.current[1]('scanInterval', 'manual') + }) + // optimistic: local state updated before the IPC round-trip resolves + expect(result.current[0].scanInterval).toBe('manual') + expect(vi.mocked(window.clean.setSetting)).toHaveBeenCalledWith('scanInterval', 'manual') + }) + + it('follows settings broadcasts and unsubscribes on unmount', async () => { + const { result, unmount } = renderHook(() => useSettings()) + act(() => bridge.emit.settingsChanged({ ...DEFAULT_SETTINGS, notify: false })) + expect(result.current[0].notify).toBe(false) + expect(bridge.listeners.settings).toBe(1) + unmount() + expect(bridge.listeners.settings).toBe(0) + }) +}) diff --git a/src/renderer/src/hooks/useToast.test.ts b/src/renderer/src/hooks/useToast.test.ts new file mode 100644 index 0000000..af63098 --- /dev/null +++ b/src/renderer/src/hooks/useToast.test.ts @@ -0,0 +1,30 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useToast } from './useToast' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('useToast', () => { + it('shows the toast then auto-clears after 2400ms', () => { + const { result } = renderHook(() => useToast()) + expect(result.current.toast).toBeNull() + act(() => result.current.flashToast('saved')) + expect(result.current.toast).toBe('saved') + act(() => vi.advanceTimersByTime(2399)) + expect(result.current.toast).toBe('saved') + act(() => vi.advanceTimersByTime(1)) + expect(result.current.toast).toBeNull() + }) + + it('re-flashing replaces the toast and restarts the clock', () => { + const { result } = renderHook(() => useToast()) + act(() => result.current.flashToast('first')) + act(() => vi.advanceTimersByTime(2000)) + act(() => result.current.flashToast('second')) + act(() => vi.advanceTimersByTime(2000)) + expect(result.current.toast).toBe('second') + act(() => vi.advanceTimersByTime(400)) + expect(result.current.toast).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/useUpdater.test.ts b/src/renderer/src/hooks/useUpdater.test.ts new file mode 100644 index 0000000..048af3f --- /dev/null +++ b/src/renderer/src/hooks/useUpdater.test.ts @@ -0,0 +1,50 @@ +import type { UpdaterState } from '@shared/updater.types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installMockClean, type MockClean } from '../test/mock-clean-bridge' +import { useUpdater } from './useUpdater' + +let bridge: MockClean +beforeEach(() => { + bridge = installMockClean() +}) + +const downloaded: UpdaterState = { + currentVersion: '1.1.0', + checkedAt: 123, + status: { + phase: 'downloaded', + info: { version: '1.2.0', releaseDate: '', sizeBytes: 0, notes: null }, + }, +} + +describe('useUpdater', () => { + it('fetches the snapshot then follows state broadcasts', async () => { + vi.mocked(window.clean.getUpdaterState).mockResolvedValue({ + currentVersion: '1.1.0', + checkedAt: null, + status: { phase: 'idle' }, + }) + const { result } = renderHook(() => useUpdater()) + await waitFor(() => expect(result.current.state.currentVersion).toBe('1.1.0')) + act(() => bridge.emit.updaterState(downloaded)) + expect(result.current.state.status.phase).toBe('downloaded') + }) + + it('exposes the three actions as bridge passthroughs', () => { + const { result } = renderHook(() => useUpdater()) + result.current.check() + result.current.download() + result.current.install() + expect(vi.mocked(window.clean.updaterCheck)).toHaveBeenCalledOnce() + expect(vi.mocked(window.clean.updaterDownload)).toHaveBeenCalledOnce() + expect(vi.mocked(window.clean.updaterInstall)).toHaveBeenCalledOnce() + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useUpdater()) + expect(bridge.listeners.updater).toBe(1) + unmount() + expect(bridge.listeners.updater).toBe(0) + }) +}) diff --git a/src/renderer/src/test/mock-clean-bridge.ts b/src/renderer/src/test/mock-clean-bridge.ts new file mode 100644 index 0000000..31f2d3c --- /dev/null +++ b/src/renderer/src/test/mock-clean-bridge.ts @@ -0,0 +1,167 @@ +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import type { LicenseState } from '@shared/license.types' +import type { Project, ScanProgress } from '@shared/project.types' +import { DEFAULT_SETTINGS } from '@shared/settings.constants' +import type { Settings } from '@shared/settings.types' +import type { UpdaterState } from '@shared/updater.types' +import { vi } from 'vitest' +import type { CleanApi } from '../../../preload/api.types' + +/** One subscription channel: records listeners, exposes an emitter and a live count. */ +function channel(): { + on: (fn: (v: T) => void) => () => void + emit: (v: T) => void + readonly count: number +} { + const listeners = new Set<(v: T) => void>() + return { + on: (fn) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + emit: (v) => { + for (const fn of listeners) fn(v) + }, + get count() { + return listeners.size + }, + } +} + +export interface MockClean { + /** The bridge installed on window.clean; override per test via vi.mocked(window.clean.method). */ + api: CleanApi + /** Push subscription events, as the main process would. */ + emit: { + scanProgress: (p: ScanProgress) => void + projectsChanged: (p: Project[]) => void + settingsChanged: (s: Settings) => void + licenseChanged: (s: LicenseState) => void + launcherNavigate: (nav: LauncherNavTarget) => void + updaterState: (s: UpdaterState) => void + } + /** Live listener counts, for asserting unsubscription on unmount. */ + listeners: { + scanProgress: number + projects: number + settings: number + license: number + launcherNavigate: number + updater: number + } +} + +export function makeProject(over: Partial = {}): Project { + return { + id: 'p1', + name: 'demo', + path: '~/code/demo', + absPath: '/Users/me/code/demo', + kind: 'node', + size: 1024, + lastUsed: 0, + ...over, + } +} + +/** + * Installs a fully-stubbed window.clean. Typed against the real CleanApi, so any + * preload bridge change breaks this file at typecheck time β€” the compiler, not + * discipline, keeps the mock in sync. + */ +export function installMockClean(): MockClean { + const scanProgress = channel() + const projects = channel() + const settings = channel() + const license = channel() + const nav = channel() + const updater = channel() + + const api: CleanApi = { + getProjects: vi.fn(async () => []), + getLastScanTime: vi.fn(async () => 0), + getPnpmStore: vi.fn(async () => ({ + available: false, + path: null, + displayPath: '', + sizeBytes: 0, + checkedAt: 0, + source: 'none' as const, + canPrune: false, + })), + prunePnpmStore: vi.fn(async () => ({ ok: true, freedBytes: 0 })), + getDocker: vi.fn(async () => ({ available: false, checkedAt: 0, totals: [], items: [], projects: [] })), + removeDockerItem: vi.fn(async () => ({ ok: true, freedBytes: 0 })), + pruneDocker: vi.fn(async () => ({ ok: true, freedBytes: 0 })), + getPackages: vi.fn(async () => null), + computePackages: vi.fn(async () => ({ packages: [], computedAt: 0, projectCount: 0 })), + openExternal: vi.fn(async () => {}), + scan: vi.fn(async () => {}), + deleteNodeModules: vi.fn(async () => ({ freed: 0 })), + deleteManyNodeModules: vi.fn(async () => ({ freed: 0, blockedIds: [] })), + revealInFinder: vi.fn(async () => {}), + openProject: vi.fn(async () => {}), + getSettings: vi.fn(async () => DEFAULT_SETTINGS), + setSetting: vi.fn(async () => DEFAULT_SETTINGS), + listVolumes: vi.fn(async () => []), + getLiveProjects: vi.fn(async () => ({})), + getLicense: vi.fn(async () => ({ pro: false })), + activateLicense: vi.fn(async () => ({ ok: false as const, reason: 'invalid' as const })), + copyShareCard: vi.fn(async () => ({ ok: true })), + openLauncher: vi.fn(async () => {}), + consumeLauncherNav: vi.fn(async () => null), + getUpdaterState: vi.fn(async () => ({ + currentVersion: '0.0.0', + checkedAt: null, + status: { phase: 'idle' as const }, + })), + updaterCheck: vi.fn(async () => {}), + updaterDownload: vi.fn(async () => {}), + updaterInstall: vi.fn(async () => {}), + closeWindow: vi.fn(async () => {}), + setWindowHeight: vi.fn(), + quitApp: vi.fn(), + uninstall: vi.fn(async () => {}), + pickPath: vi.fn(async () => null), + trackEvent: vi.fn(), + onScanProgress: vi.fn(scanProgress.on), + onProjectsChanged: vi.fn(projects.on), + onSettingsChanged: vi.fn(settings.on), + onLicenseChanged: vi.fn(license.on), + onLauncherNavigate: vi.fn(nav.on), + onUpdaterState: vi.fn(updater.on), + } + + window.clean = api + return { + api, + emit: { + scanProgress: scanProgress.emit, + projectsChanged: projects.emit, + settingsChanged: settings.emit, + licenseChanged: license.emit, + launcherNavigate: nav.emit, + updaterState: updater.emit, + }, + listeners: { + get scanProgress() { + return scanProgress.count + }, + get projects() { + return projects.count + }, + get settings() { + return settings.count + }, + get license() { + return license.count + }, + get launcherNavigate() { + return nav.count + }, + get updater() { + return updater.count + }, + }, + } +} diff --git a/src/renderer/src/test/setup.ts b/src/renderer/src/test/setup.ts new file mode 100644 index 0000000..f7aa10e --- /dev/null +++ b/src/renderer/src/test/setup.ts @@ -0,0 +1,5 @@ +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +// Vitest globals are off in this repo, so RTL's auto-cleanup never registers. +afterEach(cleanup) diff --git a/vitest.config.ts b/vitest.config.ts index f645000..133ba09 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,46 @@ export default defineConfig({ }, }, test: { - // only this checkout's sources β€” keeps temporary worktrees under .claude/ out of runs - include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + // *.ts only: .tsx (UI) is Storybook's domain and intentionally unmeasured + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.types.ts', + 'src/**/*.d.ts', + // barrels and bootstrap wiring (src/main/index.ts, src/preload/index.ts) + 'src/**/index.ts', + 'src/renderer/src/test/**', + ], + reporter: ['text', 'json-summary'], + // Ratchet: CI (Linux) numbers are authoritative β€” coverage differs slightly + // from macOS because platform-dependent branches don't run there. When a PR + // raises CI coverage, bump these in the same PR. Never lower them. + thresholds: { + statements: 75.1, + branches: 88.5, + functions: 85.7, + lines: 75.1, + }, + }, + projects: [ + { + extends: true, // inherit root resolve.alias + test: { + name: 'node', + include: ['src/main/**/*.test.ts', 'src/shared/**/*.test.ts', 'src/preload/**/*.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'renderer', + environment: 'jsdom', + include: ['src/renderer/**/*.test.ts'], + setupFiles: ['src/renderer/src/test/setup.ts'], + }, + }, + ], }, })