diff --git a/CHANGELOG.md b/CHANGELOG.md index e30e946..cb547e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] — 2026-10-04 + +The trust release: every defect from the 2026-08-31 audit (#40–#106) is fixed, +the CI gates are green again, and the write paths are re-validated against a +real development org. + +### Fixed — imports and other writes +- **The file you review is the file that's pushed** — loading a new file on Import no longer pushes a previously cleansed dataset (#44), and changing the dataset resets mapping, validation, and stage progress so stale mapped rows can't be re-pushed (#51). +- **Retry keeps your mappings** — Retry Failed Rows restores the manual mappings used for the original push instead of re-running automap (#51). +- **Per-row results everywhere** — Bulk pushes report per-row errors and record IDs mapped back to the input rows; REST results carry their input index, so out-of-order batches and 204 upserts no longer shift IDs (#47). Rows dropped at mapping no longer shift error-file rows or make Retry re-send successful rows (#46), and now appear in the error file with their mapping error. +- **Bulk import keeps every column** — the CSV header is the union of all records' fields, explicit blanks clear fields with `#N/A`, and values are sent verbatim (no spreadsheet-guard apostrophes on `-`, `+`, `@` values) (#45). +- **Production typed confirmation on every route** — Advanced → Data Push now gets the same production gate and warnings as Import (#48). +- **Relationship lookups validate** — external-ID and related-field lookups pass validation, and the match field is checked against the referenced object's describe (#50). +- **Compare sync updates instead of duplicating** — Changed records are updated by target Id; only Added records are inserted (#52). +- **Undo, Copy between orgs, and Clone** — undo targets the right org (#42); Copy between orgs writes to the target org, builds its ID map, and no longer stalls on all-failed pushes (#43); clone and migration pair IDs with source rows by result index, not position (#49, #90). +- **No duplicate inserts from lost responses** — network errors on non-idempotent writes are no longer retried (#83). +- Dates no longer shift a day in UTC+ time zones (#53); Bulk error CSVs are parsed properly (#97); aggregates no longer produce silent `NaN` (#102); pipeline joins keep colliding right-side fields (#92). + +### Fixed — schedules and long-running jobs +- Schedules keep their alarms across service-worker restarts, honour the selected time zone, and no longer revert edits or resurrect deleted schedules (#54, #55, #62). +- Snapshot writes report quota failures instead of recording success; the storage quota guard no longer blocks or recurses on writes; terminal checkpoints are pruned (#56, #63, #94, #99). +- Bulk jobs: no false "interrupted" or 10-minute-timeout failures, cancel works after restarts, no duplicate history rows, and tokens refresh mid-job (#57–#61). + +### Fixed — security and export fidelity +- Session tokens are no longer persisted to `chrome.storage.local`, are not broadcast to every UI surface, and the REST explorer only sends the token to the org's own origin (#64, #65, #67). Privileged message handlers check the sender (#68). +- CSV and Excel exports neutralise spreadsheet formulas in text cells without turning real negative numbers into text (#66). +- Exports keep every column (no 14-column truncation, no 50-row sampling), drop nested `attributes`, write a UTF-8 BOM for Excel, and surface export failures (#69, #70, #72, #73, #79). +- Bulk exports type values from field metadata, matching REST — without corrupting leading-zero text, long IDs, or text that looks like `true` (#80). +- Snapshots, Compare, Convert, Saved Jobs, Activity, and SOQL escaping fixes (#71, #74–#78, #81, #82, #91, #93). + +### Changed +- **Store listing** — new name ("WaveLink — Salesforce SOQL Export & Data Import Tool") and summary; permission declarations match the manifest (`unlimitedStorage` added, `activeTab` removed). +- The global Ctrl+Z shortcut no longer hijacks text-field undo, and the legacy tutorial no longer auto-opens (#84, #85). Accessibility attributes added across 34 components (#103). +- Upgraded Jest to 30 and typescript-eslint to 8, clearing all dependency audit advisories. +- `npm run validate:salesforce` now also checks Bulk per-row identity, ragged columns, blank-means-clear, verbatim values, and Bulk-vs-REST value types. + ## [0.6.0] — 2026-08-31 ### Added @@ -83,6 +119,7 @@ in and out of Salesforce right from your browser, with nothing leaving your devi - Initial release. +[0.7.0]: https://github.com/Exotic209093/WaveLink/releases/tag/v0.7.0 [0.6.0]: https://github.com/Exotic209093/WaveLink/releases/tag/v0.6.0 [0.2.0]: https://github.com/Exotic209093/WaveLink/releases/tag/v0.2.0 [0.1.0]: https://github.com/Exotic209093/WaveLink/releases/tag/v0.1.0 diff --git a/PRIVACY.md b/PRIVACY.md index 65c06ba..2d28b0d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -43,9 +43,9 @@ When you request an operation, the necessary authentication information, Salesfo ## 5. Chrome permission explanations -- **storage:** saves organisation connections, tokens, queries, mappings, jobs, schedules, snapshots, results, activity, and preferences locally. +- **storage:** saves organisation connections, queries, mappings, jobs, schedules, snapshots, results, activity, and preferences locally. - **cookies:** reads the `sid` cookie from supported Salesforce domains to authenticate to an existing Salesforce session. -- **activeTab:** identifies the Salesforce context in the tab where you invoke WaveLink. +- **unlimitedStorage:** lets locally retained snapshots, checkpoints, and results exceed Chrome's default 10 MB extension-storage quota. The data stays on your device. - **tabs:** finds open Salesforce tabs, lets you choose a connected organisation, and opens the full Extension workspace. - **alarms:** wakes the Extension to run locally configured export schedules. - **offscreen:** provides a local extension document for eligible long-running job and file-processing work when a visible page is not available. diff --git a/docs/CHROME_WEB_STORE.md b/docs/CHROME_WEB_STORE.md index 483ace3..ea1906b 100644 --- a/docs/CHROME_WEB_STORE.md +++ b/docs/CHROME_WEB_STORE.md @@ -1,6 +1,6 @@ # Chrome Web Store release kit -This file is the source of truth for the WaveLink 0.6.0 listing and privacy declarations. Copy text exactly unless the Chrome Web Store dashboard requires a shorter value. +This file is the source of truth for the WaveLink 0.7.0 listing and privacy declarations. Copy text exactly unless the Chrome Web Store dashboard requires a shorter value. ## Dashboard and public links @@ -18,49 +18,59 @@ npm run assets:store npm run package ``` -Upload `wavelink-0.6.0.zip`. The package contains the compiled Manifest V3 extension, icons, and bundled privacy page; it excludes source, tests, and build-only files. +Upload `wavelink-0.7.0.zip`. The package contains the compiled Manifest V3 extension, icons, and bundled privacy page; it excludes source, tests, and build-only files. ## Store listing ### Product name -> WaveLink — Salesforce Data Export & Import +> WaveLink — Salesforce SOQL Export & Data Import Tool -The product name comes from `public/manifest.json` and is 42 characters. +The product name comes from `public/manifest.json` and is 52 characters (limit 75). ### Summary -> Safely export, import, compare, schedule, and repeat Salesforce data jobs—directly from your browser. +> Export Salesforce data with SOQL to CSV, Excel, JSON or XML right in Chrome. No Java, no install, no server. Free and open source. -The summary comes from `public/manifest.json` and is 101 characters, below Chrome's 132-character limit. +The summary comes from `public/manifest.json` and is 130 characters, below Chrome's 132-character limit. ### Detailed description -> Move data in and out of your org with confidence. +> Get Salesforce data out in seconds — straight from the browser tab you're already logged into. > -> WaveLink is a local-first data workspace for administrators, developers, and consultants. Query and export records, validate imports before they run, compare snapshots, schedule recurring exports, and replay saved jobs—all without sending customer data to a WaveLink server. +> WaveLink is a free, open-source data workspace for Salesforce admins, developers, and consultants. Write or build a SOQL query, preview the records, pick your columns, and download CSV, Excel, JSON, or XML. No Java runtime, no desktop Data Loader install, no third-party server holding your data. > -> WHAT YOU CAN DO +> EXPORT > -> • Export with SOQL through REST or Bulk API 2.0 -> • Download CSV, JSON, Excel, or XML with only the columns you choose -> • Import CSV, JSON, and XLSX through a guided mapping and validation flow -> • Preview impact with dry runs, production warnings, and typed confirmation -> • Retry failed rows, download results, and undo supported inserts -> • Save reusable jobs and schedule local snapshots -> • Compare files, snapshots, or connected orgs -> • Copy a reviewed single-object dataset between connected orgs -> • Inspect objects, records, API usage, and schemas in Advanced tools +> • SOQL editor with autocomplete, plus a visual query builder for fields, filters, GROUP BY, and aggregates +> • REST for quick queries, Bulk API 2.0 for large objects — with progress and cancel +> • CSV, Excel (XLSX), JSON, or XML with only the columns you choose +> • Save queries as reusable jobs and re-run them in one click > -> BUILT FOR SAFER DATA WORK +> SNAPSHOTS AND COMPARE > -> The target org, environment, operation, and record count remain visible before a write. Bulk jobs keep resumable checkpoints, and unified activity history makes results and eligible recovery actions easy to find. +> • Schedule recurring local snapshots of key objects +> • Compare two files, two snapshots, or two connected orgs field-by-field > -> LOCAL-FIRST PRIVACY +> IMPORT > -> WaveLink has no analytics, telemetry, advertising, or developer-operated backend. Data is stored in your browser and exchanged only with Salesforce domains selected by you. Uploaded records, query results, snapshots, job history, account details, and authentication information are handled only to provide the features you request. See the privacy policy for complete handling and deletion details. +> • Guided CSV, JSON, and Excel import with field mapping, validation, and dry runs +> • Production warnings and typed confirmation before writes > -> Requires an active Salesforce browser session. WaveLink is independent and is not affiliated with or endorsed by Salesforce, Inc. +> ADVANCED +> +> • Record Inspector, object and field browser, REST/Tooling API explorer, anonymous Apex, and API usage +> +> PRIVATE BY DESIGN +> +> WaveLink has no analytics, telemetry, advertising, or WaveLink backend. Your records stay in your browser and are exchanged only with the Salesforce orgs you select. Source code: https://github.com/Exotic209093/WaveLink +> +> Uses your existing Salesforce browser session. WaveLink is independent and is not affiliated with or endorsed by Salesforce, Inc. + +### Localised listing + +Add **Español** in the dashboard with the Spanish summary and description in +[`launch-kit.md`](launch-kit.md#spanish-listing-store-listing--add-language--español). ### Category and language @@ -69,15 +79,15 @@ The summary comes from `public/manifest.json` and is 101 characters, below Chrom ### Release notes -> WaveLink 0.6.0 is a major workflow, safety, and reliability update: +> WaveLink 0.7.0 is the "data you can trust" release — every known data-corruption, automation, and security defect from the August audit is fixed: > -> • New task-first interface and seven-stage Guided Import -> • Unified Saved Jobs, Schedules, Snapshots, and Activity -> • Bulk API 2.0 query support and resumable job checkpoints -> • CSV, JSON, Excel, and XML exports with selected-column support -> • Production typed confirmation and clearer org context -> • Improved accessibility, performance budgets, and package security -> • A focused, single-object Copy flow replaces the former migration suite +> • Imports push exactly the file and rows you reviewed; retry and error files target the right rows +> • Bulk imports keep every column, clear fields correctly, and report per-row results +> • Production typed confirmation on every write route; relationship lookups validate +> • Undo, Copy between orgs, and Compare sync work correctly (no duplicates) +> • Schedules survive browser restarts and fire on time in your time zone +> • Session tokens no longer stored at rest; exports neutralise spreadsheet formulas +> • Exports keep every column, non-ASCII text, and REST-consistent value types If no release-notes field is shown, keep this text for the submission notes rather than appending it to the permanent description. @@ -88,15 +98,15 @@ Upload in this order: | Order | File | Dimensions | Purpose | |---:|---|---:|---| | Icon | `public/icons/icon-128.png` | 128×128 | Store and install icon | -| 1 | `screenshots/screenshot-01-home.png` | 1280×800 | Connected Home workspace | -| 2 | `screenshots/screenshot-02-export.png` | 1280×800 | SOQL export and results | -| 3 | `screenshots/screenshot-03-import-review.png` | 1280×800 | Production-aware import review | -| 4 | `screenshots/screenshot-04-compare.png` | 1280×800 | Compare workspace | -| 5 | `screenshots/screenshot-05-activity.png` | 1280×800 | Jobs and activity history | +| 1 | `screenshots/screenshot-02-export.png` | 1280×800 | SOQL export and results | +| 2 | `screenshots/screenshot-01-home.png` | 1280×800 | Connected Home workspace | +| 3 | `screenshots/screenshot-04-compare.png` | 1280×800 | Compare workspace | +| 4 | `screenshots/screenshot-05-activity.png` | 1280×800 | Jobs and activity history | +| 5 | `screenshots/screenshot-03-import-review.png` | 1280×800 | Production-aware import review | | Small promo | `screenshots/promo-small-440x280.png` | 440×280 | Required promotional tile | | Marquee promo | `screenshots/promo-marquee-1400x560.png` | 1400×560 | Optional large promotional tile | -All screenshots are captures of v0.6.0 at the required dimensions. Organisation, user, and record identifiers are redacted. Regenerate promotional graphics with `npm run assets:store`. +All screenshots are captures of v0.6.0 at the required dimensions; the 0.7.0 changes are fixes with no visible layout change on these screens. Organisation, user, and record identifiers are redacted. Regenerate promotional graphics with `npm run assets:store`. ## Privacy practices @@ -108,15 +118,15 @@ All screenshots are captures of v0.6.0 at the required dimensions. Organisation, **storage** -> Saves selected Salesforce org connections and authentication information, queries, mappings, reusable jobs, schedules, snapshots, results, activity history, checkpoints, undo information, and preferences in Chrome extension storage. This keeps the workspace available across extension sessions without a WaveLink backend. +> Saves selected Salesforce org connections (without access tokens, which are kept only in memory-backed session storage), queries, mappings, reusable jobs, schedules, snapshots, results, activity history, checkpoints, undo information, and preferences in Chrome extension storage. This keeps the workspace available across extension sessions without a WaveLink backend. **cookies** > Reads the Salesforce `sid` session cookie from supported Salesforce domains so the user can connect an already authenticated org and make requested API calls. WaveLink does not read cookies from unrelated domains. -**activeTab** +**unlimitedStorage** -> Identifies the Salesforce context in the tab where the user invokes WaveLink. It is not used to inspect unrelated page content. +> Lets locally retained snapshots, job checkpoints, and result files exceed Chrome's default 10 MB extension-storage quota so scheduled snapshots and large jobs are not silently truncated. All of this data stays on the user's device and can be purged from Settings. **tabs** @@ -174,14 +184,14 @@ The repository must be pushed before saving this URL so reviewers can reach the ## Final submission checklist -- [ ] Push the v0.6.0 code and public privacy policy to `main`. +- [ ] Push the v0.7.0 code and public privacy policy to `main`. - [ ] Confirm the privacy-policy URL loads while signed out of GitHub. -- [ ] Upload `wavelink-0.6.0.zip` and confirm version 0.6.0 is detected. +- [ ] Upload `wavelink-0.7.0.zip` and confirm version 0.7.0 is detected. - [ ] Replace the description with the text in this file. - [ ] Upload all five screenshots in the documented order. - [ ] Upload the icon and promotional tiles. - [ ] Verify category, language, homepage, support URL, and privacy URL. - [ ] Reconfirm every permission and data-use declaration against the uploaded package. -- [ ] Add the v0.6.0 release notes where the dashboard permits. +- [ ] Add the v0.7.0 release notes where the dashboard permits. - [ ] Preview the public listing at desktop width and check every image crop. - [ ] Save the draft, review the dashboard's warnings, and submit only after a final joint check. diff --git a/docs/launch-kit.md b/docs/launch-kit.md new file mode 100644 index 0000000..877851a --- /dev/null +++ b/docs/launch-kit.md @@ -0,0 +1,241 @@ +# WaveLink launch kit + +Prepared 2026-10-04 from the Chrome Web Store analytics for 2026-07-05 to 2026-10-02. + +## Baseline (record before launch) + +| Metric | Value | Source | +|---|---:|---| +| Weekly users | 15 | Users, last 30 days | +| Store impressions | 87 / 30 days | Impressions | +| Listing page views | 16 / 30 days | Impressions | +| Installs | 130 / 90 days (~10 appear human; the rest are a steady US ChromeOS trickle with no language data that never shows up as users) | Installs & uninstalls | +| Uninstalls | 2 / 90 days | Installs & uninstalls | +| Campaign / medium traffic | none | Impressions | + +Target: **50 weekly users by 2026-12-31.** Review the dashboard every Monday and log the four numbers above. + +## Before posting anywhere + +1. **Ship 0.7.0 first.** It contains every audit fix (import, schedules, + security, export fidelity). Launch traffic should land on 0.7.0, not 0.6.0. +2. **Then promote freely.** With the v0.7.0 import fixes released, import can be + promoted alongside export; export is still the strongest hook for search. + +## Store listing v2 + +The name and summary live in `public/manifest.json`, so they change with the next +package upload. The description, screenshots, and localised listing are edited +in the dashboard. + +### Name (52 / 75 characters) + +> WaveLink — Salesforce SOQL Export & Data Import Tool + +### Summary (≤132 characters) + +> Export Salesforce data with SOQL to CSV, Excel, JSON or XML right in Chrome. No Java, no install, no server. Free and open source. + +### Detailed description + +> Get Salesforce data out in seconds — straight from the browser tab you're already logged into. +> +> WaveLink is a free, open-source data workspace for Salesforce admins, developers, and consultants. Write or build a SOQL query, preview the records, pick your columns, and download CSV, Excel, JSON, or XML. No Java runtime, no desktop Data Loader install, no third-party server holding your data. +> +> EXPORT +> +> • SOQL editor with autocomplete, plus a visual query builder for fields, filters, GROUP BY, and aggregates +> • REST for quick queries, Bulk API 2.0 for large objects — with progress and cancel +> • CSV, Excel (XLSX), JSON, or XML with only the columns you choose +> • Save queries as reusable jobs and re-run them in one click +> +> SNAPSHOTS AND COMPARE +> +> • Schedule recurring local snapshots of key objects +> • Compare two files, two snapshots, or two connected orgs field-by-field +> +> IMPORT +> +> • Guided CSV, JSON, and Excel import with field mapping, validation, and dry runs +> • Production warnings and typed confirmation before writes +> +> ADVANCED +> +> • Record Inspector, object and field browser, REST/Tooling API explorer, anonymous Apex, and API usage +> +> PRIVATE BY DESIGN +> +> WaveLink has no analytics, telemetry, advertising, or WaveLink backend. Your records stay in your browser and are exchanged only with the Salesforce orgs you select. Source code: https://github.com/Exotic209093/WaveLink +> +> Uses your existing Salesforce browser session. WaveLink is independent and is not affiliated with or endorsed by Salesforce, Inc. + +### Screenshot order + +Put export first; it is the safest and most searched-for job. + +1. `screenshots/screenshot-02-export.png` — SOQL export and results +2. `screenshots/screenshot-01-home.png` — Home workspace +3. `screenshots/screenshot-04-compare.png` — Compare +4. `screenshots/screenshot-05-activity.png` — Jobs and activity +5. `screenshots/screenshot-03-import-review.png` — Import review + +Add the promo video (`promo/output/wavelink-promo-full-polished.mp4`) as an +unlisted YouTube upload and paste the URL into the listing's video field. + +### Spanish listing (Store listing → add language → Español) + +**Resumen** + +> Exporta datos de Salesforce con SOQL a CSV, Excel, JSON o XML desde Chrome. Sin Java ni instalaciones. Gratis y de código abierto. + +**Descripción** + +> Saca tus datos de Salesforce en segundos, desde la pestaña en la que ya iniciaste sesión. +> +> WaveLink es un espacio de trabajo de datos gratuito y de código abierto para administradores, desarrolladores y consultores de Salesforce. Escribe o arma una consulta SOQL, revisa los registros, elige las columnas y descarga en CSV, Excel, JSON o XML. Sin Java, sin instalar Data Loader y sin servidores de terceros con tus datos. +> +> EXPORTAR +> +> • Editor SOQL con autocompletado y constructor visual de consultas +> • REST para consultas rápidas y Bulk API 2.0 para objetos grandes +> • CSV, Excel (XLSX), JSON o XML solo con las columnas que elijas +> • Guarda consultas como trabajos reutilizables +> +> SNAPSHOTS Y COMPARACIÓN +> +> • Programa snapshots locales periódicos +> • Compara archivos, snapshots u orgs conectadas campo por campo +> +> IMPORTAR +> +> • Importación guiada de CSV, JSON y Excel con mapeo de campos, validación y simulación +> +> PRIVACIDAD +> +> Sin analítica, telemetría, publicidad ni servidores de WaveLink. Tus registros se quedan en tu navegador. Código fuente: https://github.com/Exotic209093/WaveLink +> +> WaveLink es independiente y no está afiliado ni respaldado por Salesforce, Inc. + +Have a native speaker skim this before publishing; it is written in neutral Latin American Spanish. + +## Tracked links + +Base: `https://chromewebstore.google.com/detail/wavelink/ccknhhibbedolfnbgnenomdohlmojblo` + +| Channel | Link suffix | +|---|---| +| Reddit | `?utm_source=reddit&utm_medium=social&utm_campaign=launch-2026-10` | +| LinkedIn (personal) | `?utm_source=linkedin&utm_medium=social&utm_campaign=launch-2026-10` | +| LinkedIn (company) | `?utm_source=linkedin-apex&utm_medium=social&utm_campaign=launch-2026-10` | +| Trailblazer Community | `?utm_source=trailblazer&utm_medium=community&utm_campaign=launch-2026-10` | +| SFXD Discord | `?utm_source=sfxd&utm_medium=community&utm_campaign=launch-2026-10` | +| Stack Exchange | `?utm_source=sfse&utm_medium=answer&utm_campaign=evergreen` | +| GitHub README | `?utm_source=github&utm_medium=readme&utm_campaign=evergreen` | +| Review request | `?utm_source=email&utm_medium=direct&utm_campaign=reviews` | + +These populate the dashboard's "Page views by campaign / medium" charts. + +## Posts + +Space the posts over a week rather than one day, and reply to every comment +within a few hours — early engagement is most of the reach. + +### Reddit — r/salesforce + +Check the subreddit's current self-promotion rule first; post as a personal +project, not an ad. + +**Title:** I built a free, open-source Chrome extension for exporting Salesforce data with SOQL — no Java or Data Loader install + +**Body:** + +> I'm a Salesforce consultant and got tired of firing up Data Loader (and its Java install) every time I needed a quick export, so I built WaveLink. +> +> It runs in the browser tab you're already logged into: +> +> - Write SOQL (with autocomplete) or use the visual builder +> - Preview records, pick columns, download CSV / Excel / JSON / XML +> - Bulk API 2.0 for big objects +> - Schedule local snapshots and diff two files, snapshots, or orgs +> +> There's no backend — no analytics, no telemetry, and records never touch a server of mine. It's MIT-licensed, so you can read every line: https://github.com/Exotic209093/WaveLink +> +> Chrome Web Store: +> +> It also has guided import, but I'd treat export as the mature part today. I'd genuinely like feedback — what's missing that would make you use it instead of Data Loader or Inspector? + +### LinkedIn — personal + +> Every Salesforce admin knows the routine: you need a quick export, so you open Data Loader, wait for Java, log in again, and click through five screens. +> +> I built WaveLink to skip all that. It's a free Chrome extension that uses the Salesforce session you already have: +> +> → Write or build a SOQL query +> → Preview the records and pick your columns +> → Download CSV, Excel, JSON, or XML +> +> It handles large objects through Bulk API 2.0, can schedule local snapshots, and compares two orgs field-by-field. +> +> Privacy was non-negotiable: no analytics, no backend, and your records never leave your browser. The code is open source on GitHub. +> +> Try it here: +> +> If you try it, I'd love to hear what you'd want next. +> +> #Salesforce #SalesforceAdmin #Trailblazer #SOQL + +Attach the promo video natively (upload the MP4) rather than linking YouTube. + +### LinkedIn — Apex Infinity Solutions page + +> Our team built WaveLink, a free Chrome extension for exporting Salesforce data with SOQL — straight to CSV, Excel, JSON, or XML, with no Java or desktop install. +> +> It's open source, has no backend, and keeps your records in your browser. We use it on client projects every day. +> +> Add it to Chrome: +> +> #Salesforce #SalesforceConsulting + +### Trailblazer Community (Admin / Developer groups) + +**Title:** Free browser tool for SOQL exports to CSV/Excel — feedback welcome + +> Sharing a free, open-source Chrome extension I built for quick Salesforce exports: write SOQL (or use a visual builder), preview, choose columns, and download CSV, Excel, JSON, or XML. Bulk API 2.0 handles large objects, and it can schedule local snapshots. +> +> It needs no Java or desktop install, and there's no backend — data stays in your browser. +> +> +> +> I'm looking for feedback from admins on what would make it part of your daily toolkit. + +### SFXD Discord (#show-and-tell or equivalent) + +> Built a free, MIT-licensed Chrome extension for SOQL exports → CSV / Excel / JSON / XML, using your existing session. Bulk API 2.0, scheduled local snapshots, org-to-org compare. No backend or telemetry. Feedback very welcome: + +### Salesforce Stack Exchange + +Do not post the link as a standalone answer. Answer questions about exporting +query results, SOQL to CSV, or Data Loader alternatives properly first; mention +WaveLink as one option at the end with a disclosure: + +> Disclosure: I wrote WaveLink, a free open-source Chrome extension that does this — . + +## Review request + +Send individually (not BCC) to colleagues and friendly client admins who have +actually used WaveLink. + +**Subject:** Quick favour — a review for WaveLink? + +> Hi , +> +> Thanks for trying WaveLink. It's still new on the Chrome Web Store and has no reviews yet, which makes it hard for other admins to find. +> +> If it's been useful, would you leave a short, honest review? It takes about a minute: → "Add a review". +> +> If something didn't work, I'd rather hear it directly — just reply here. +> +> Thanks, +> James + +Don't offer anything in return for reviews; the Chrome Web Store policies prohibit incentivised ratings. diff --git a/docs/release-validation.md b/docs/release-validation.md index 90dad66..990e2f0 100644 --- a/docs/release-validation.md +++ b/docs/release-validation.md @@ -99,6 +99,19 @@ packaged-extension browser scenarios above. caveat is that the already-open normal Chrome profile was not remotely attachable; it was not modified or restarted. +#### 2026-10-04 — v0.7.0 service-layer validation, `nebula-dev` + +- Org suffix `…28TUAS`, `IsSandbox=false`, API v63.0, branch `release/v0.7.0`. +- Read-only and write-mode `npm run validate:salesforce`: all 13 checks passed, + cleanup `passed`. +- New v0.7.0 checks: Bulk per-row identity (failure mapped to input row 1, IDs + to rows 0 and 2; job `750dL000012hMq1QAE`), a column absent from row 0 still + uploaded, `+44…` values stored verbatim, explicit null cleared Phone through + Bulk update (`750dL000012hIb8QAE`), and Bulk-typed query values matched REST + for populated number, currency, and boolean fields (`750dL000012h54TQAQ`). +- Packaged-extension browser check of the 0.7.0 `dist` build: completed by the + maintainer on 2026-10-04 with no issues reported. + ## Maintainer-authorized usability gate Run the packaged build without using implementation notes: diff --git a/package-lock.json b/package-lock.json index 845d641..608ca1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wavelink", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wavelink", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "papaparse": "^5.5.2", @@ -17,22 +17,22 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/preact": "^3.2.4", "@types/chrome": "^0.0.268", - "@types/jest": "^29.5.12", + "@types/jest": "^30.0.0", "@types/jest-axe": "^3.5.9", "@types/papaparse": "^5.3.16", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^8.71.0", + "@typescript-eslint/parser": "^8.71.0", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "eslint": "^8.57.0", "eslint-plugin-jsx-a11y": "^6.10.2", "html-webpack-plugin": "^5.6.0", - "jest": "^29.7.0", + "jest": "^30.5.2", "jest-axe": "^10.0.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.2", "sharp": "^0.35.4", "style-loader": "^4.0.0", - "ts-jest": "^29.2.0", + "ts-jest": "^29.4.14", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "^5.5.0", @@ -47,6 +47,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -200,9 +221,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -367,13 +388,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -493,13 +514,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -597,6 +618,121 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -607,6 +743,18 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -618,6 +766,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -672,9 +831,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -722,9 +881,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1342,6 +1501,109 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1460,61 +1722,61 @@ } }, "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", + "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.5.1", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", + "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.5.2", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.2", + "jest-runner": "30.5.2", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.2", + "pretty-format": "30.5.1", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1525,232 +1787,413 @@ } } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@jest/core/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@jest/core/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/diff-sequences": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@jest/environment": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.2.tgz", + "integrity": "sha512-8/P+8hKv7L4mhFoB6gpYLj/fuzXk8PWrde7TDHh8tJz5pE1ktlMbg0y6hDY/V8MfAnispFlPl/vPifowLy3JGQ==", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "@types/jsdom": "*", + "canvas": "^3.0.0", + "jsdom": "*" }, "peerDependenciesMeta": { - "node-notifier": { + "canvas": { "optional": true } } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@jest/expect": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "expect": "30.5.2", + "jest-snapshot": "30.5.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "node_modules/@jest/expect-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@jest/get-type": "30.5.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "node_modules/@jest/fake-timers": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@jest/types": "30.5.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", + "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/types": "30.5.1", + "jest-mock": "30.5.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", + "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/snapshot-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", + "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", + "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.2", + "@jest/types": "30.5.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", + "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.5.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", + "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", + "@babel/core": "^7.27.4", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", + "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pirates": "^4.0.7", "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "write-file-atomic": "^5.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/types/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1812,6 +2255,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1850,82 +2315,424 @@ "node": ">= 8" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", "dev": true, - "license": "BSD-3-Clause", + "hasInstallScript": true, + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=10" + "node": ">= 10.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "deep-equal": "^2.0.5" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" }, "node_modules/@testing-library/dom/node_modules/pretty-format": { "version": "27.5.1", @@ -1985,16 +2792,6 @@ "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -2023,6 +2820,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2132,16 +2940,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/har-format": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", @@ -2184,14 +2982,14 @@ } }, "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, "node_modules/@types/jest-axe": { @@ -2215,37 +3013,86 @@ "node": ">=4" } }, - "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/papaparse": { - "version": "5.5.2", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", "dev": true, @@ -2286,122 +3133,159 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2409,75 +3293,88 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2485,106 +3382,460 @@ "dev": true, "license": "ISC" }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", @@ -2707,14 +3958,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2728,17 +3971,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-import-phases": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", @@ -2776,16 +4008,13 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -2980,16 +4209,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.flat": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", @@ -3067,13 +4286,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3111,85 +4323,58 @@ } }, "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", + "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", + "@jest/transform": "30.5.2", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/babel-preset-current-node-syntax": { @@ -3220,20 +4405,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.5.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/balanced-match": { @@ -3244,13 +4429,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/boolbase": { @@ -3261,32 +4449,32 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3304,11 +4492,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3429,9 +4617,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -3487,9 +4675,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -3503,9 +4691,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, @@ -3597,19 +4785,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -3658,28 +4833,6 @@ "webpack": "^5.1.0" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3788,33 +4941,20 @@ "node": ">=4" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -3823,18 +4963,17 @@ "license": "BSD-2-Clause" }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -3917,9 +5056,9 @@ "license": "MIT" }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4017,16 +5156,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4067,19 +5196,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4148,20 +5264,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", @@ -4219,10 +5321,17 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, @@ -4426,9 +5535,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -4518,28 +5627,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -4628,9 +5715,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4689,9 +5776,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4824,30 +5911,32 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/fast-deep-equal": { @@ -4857,36 +5946,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4902,9 +5961,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -4961,19 +6020,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5039,21 +6085,34 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">= 6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/fs.realpath": { @@ -5063,21 +6122,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5275,9 +6319,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5331,27 +6375,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5506,16 +6529,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-escaper": { @@ -5611,32 +6634,31 @@ } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -6066,16 +7088,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -6348,15 +7360,15 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" @@ -6376,23 +7388,39 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", + "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.5.2", + "@jest/types": "30.5.1", + "import-local": "^3.2.0", + "jest-cli": "30.5.2" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -6446,132 +7474,252 @@ } }, "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.5.1", "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", + "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.5.1", + "pure-rand": "^7.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "node_modules/jest-circus/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "node_modules/jest-circus/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", + "license": "MIT" + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", + "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", + "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.2", + "@jest/types": "30.5.1", + "babel-jest": "30.5.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-circus": "30.5.2", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "pretty-format": "30.5.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "@types/node": "*", + "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "esbuild-register": { + "optional": true + }, "ts-node": { "optional": true } } }, + "node_modules/jest-config/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-config/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -6585,383 +7733,748 @@ "pretty-format": "^29.7.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", + "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.2.tgz", + "integrity": "sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.2", + "@jest/environment-jsdom-abstract": "30.5.2", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", + "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "node_modules/jest-message-util/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "node_modules/jest-message-util/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/jest-mock": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-util": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "node_modules/jest-resolve": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.12.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "node_modules/jest-resolve-dependencies": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", + "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "node_modules/jest-runner": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", + "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/console": "30.5.2", + "@jest/environment": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.2", + "jest-util": "30.5.1", + "jest-watcher": "30.5.2", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "node_modules/jest-runtime": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", + "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/globals": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.2.0", + "collect-v8-coverage": "^1.0.2", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "strip-bom": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/jest-runtime/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/jest-snapshot": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", + "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.5.2", + "graceful-fs": "^4.2.11", + "jest-diff": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "node_modules/jest-snapshot/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/types": "30.5.1", "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "node_modules/jest-validate": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.5.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/jest-validate/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "node_modules/jest-validate/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/jest-validate/node_modules/camelcase": { @@ -6977,40 +8490,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", + "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "jest-util": "30.5.1", + "string-length": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-util": "^29.7.0", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.5.1", "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "supports-color": "^8.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker/node_modules/supports-color": { @@ -7060,44 +8590,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -7195,16 +8719,6 @@ "node": ">=0.10.0" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -7360,16 +8874,6 @@ "dev": true, "license": "ISC" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7387,30 +8891,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -7455,16 +8935,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7480,6 +8960,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7506,6 +8996,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7531,6 +9037,13 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7539,11 +9052,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -7582,9 +9098,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", "dev": true, "license": "MIT" }, @@ -7792,6 +9308,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -7902,14 +9425,31 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/picocolors": { @@ -8114,9 +9654,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -8193,33 +9733,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8231,9 +9744,9 @@ } }, "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -8247,13 +9760,6 @@ ], "license": "MIT" }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8397,13 +9903,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8458,16 +9957,6 @@ "node": ">=4" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8496,6 +9985,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8666,9 +10162,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8893,13 +10389,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8930,17 +10419,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9014,6 +10492,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -9102,6 +10596,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -9198,6 +10706,22 @@ "dev": true, "license": "MIT" }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -9342,42 +10866,90 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-table": { @@ -9435,82 +11007,79 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "tldts-core": "^6.1.86" }, - "engines": { - "node": ">=8.0" + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.14", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.14.tgz", + "integrity": "sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -9521,13 +11090,13 @@ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", + "@babel/core": ">=7.0.0-beta.0 <9", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -9564,24 +11133,41 @@ } }, "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.2.tgz", + "integrity": "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", + "picomatch": "^4.0.0", "source-map": "^0.7.4" }, "engines": { "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/ts-loader/node_modules/source-map": { @@ -9813,20 +11399,48 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -9864,17 +11478,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9912,26 +11515,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" + "node": ">=18" } }, "node_modules/watchpack": { @@ -10113,9 +11706,9 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", @@ -10123,31 +11716,31 @@ "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { @@ -10297,6 +11890,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10305,23 +11917,36 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -10353,13 +11978,13 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { @@ -10387,9 +12012,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 478a9ca..ec5f50f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wavelink", - "version": "0.6.0", + "version": "0.7.0", "description": "A local-first Chrome extension for safer Salesforce data exports, imports, comparisons, schedules, and reusable jobs.", "private": true, "scripts": { @@ -29,22 +29,22 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/preact": "^3.2.4", "@types/chrome": "^0.0.268", - "@types/jest": "^29.5.12", + "@types/jest": "^30.0.0", "@types/jest-axe": "^3.5.9", "@types/papaparse": "^5.3.16", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^8.71.0", + "@typescript-eslint/parser": "^8.71.0", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "eslint": "^8.57.0", "eslint-plugin-jsx-a11y": "^6.10.2", "html-webpack-plugin": "^5.6.0", - "jest": "^29.7.0", + "jest": "^30.5.2", "jest-axe": "^10.0.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.2", "sharp": "^0.35.4", "style-loader": "^4.0.0", - "ts-jest": "^29.2.0", + "ts-jest": "^29.4.14", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "^5.5.0", diff --git a/public/manifest.json b/public/manifest.json index 5a81350..3ffdaeb 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "WaveLink — Salesforce Data Export & Import", - "version": "0.6.0", - "description": "Safely export, import, compare, schedule, and repeat Salesforce data jobs—directly from your browser.", + "name": "WaveLink — Salesforce SOQL Export & Data Import Tool", + "version": "0.7.0", + "description": "Export Salesforce data with SOQL to CSV, Excel, JSON or XML right in Chrome. No Java, no install, no server. Free and open source.", "homepage_url": "https://github.com/Exotic209093/WaveLink", "permissions": [ "storage", diff --git a/roadmap.md b/roadmap.md index 92355cf..60756a4 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,11 +1,21 @@ # WaveLink Product Roadmap -> Last updated: 2026-08-31
-> Prepared release: **v0.6.0**; current Chrome Web Store release: **v0.2.0**
+> Last updated: 2026-10-04
+> Prepared release: **v0.7.0**; current Chrome Web Store release: **v0.6.0**
> Product direction: **a fast, local-first Salesforce data workspace for safely exporting, importing, comparing, scheduling, and repeating data jobs** ## Road to 1.0 (2026-08-31 audit) +> **Status (2026-10-04):** all 46 audit issues and the follow-ups #88–#106 are +> fixed and ship together in **v0.7.0** — a deliberate decision to release the +> v0.8.0–v1.0.0 milestone work in one store update rather than four. The fixes +> for #43, #49, #56/#63/#94/#99, #66, and #80 were reworked after review found +> them incomplete; CI, red on `main` from #107 to #125, is green again. +> Write paths were re-validated with `npm run validate:salesforce` against the +> `nebula-dev` org (see `docs/release-validation.md`). The maintainer completed the +> packaged-extension browser check on 0.7.0. Remaining before 1.0: post-release +> field feedback. + A full-project test pass on 2026-08-31 at commit `74cf21b` found the automated gates all green — TypeScript, ESLint, `npm test` (499 tests / 49 suites), the production build, the Manifest V3 package smoke, and both dependency audits (0 diff --git a/scripts/validate-salesforce-org.ts b/scripts/validate-salesforce-org.ts index bef6347..58dc6d3 100644 --- a/scripts/validate-salesforce-org.ts +++ b/scripts/validate-salesforce-org.ts @@ -12,6 +12,8 @@ import { SalesforceApiClient } from '../src/services/salesforce/api-client'; import { BulkApiService } from '../src/services/salesforce/bulk-api'; +import { buildBulkRowIdentity, fetchBulkRowResults } from '../src/services/salesforce/bulk-results'; +import { typeBulkQueryPage } from '../src/services/salesforce/bulk-query-typing'; import type { ApiVersion, BulkJob } from '../src/core/types/salesforce'; import path from 'node:path'; @@ -104,6 +106,36 @@ async function completeIngest(service: BulkApiService, job: BulkJob): Promise { + const restRows = await api.query>(typingSoql); + const typingJob = await bulk.createQueryJob(typingSoql); + await pollQuery(bulk, typingJob.id); + const typingStatus = await bulk.getQueryJobStatus(typingJob.id); + assert(typingStatus.object === 'Account', `Bulk query job info reported object "${typingStatus.object}", expected Account.`); + const typingPage = await bulk.getQueryResults(typingJob.id, undefined, 100); + const typedRows = await typeBulkQueryPage(typingPage.records, typingStatus.object, name => api.describeSObject(name)); + const typedById = new Map(typedRows.map(row => [String(row.Id), row])); + let compared = 0; + for (const restRow of restRows.records) { + const bulkRow = typedById.get(String(restRow.Id)); + assert(bulkRow, `Bulk results are missing REST record ${String(restRow.Id).slice(-6)}.`); + for (const fieldName of ['Name', 'NumberOfEmployees', 'AnnualRevenue', 'IsDeleted', 'CreatedDate']) { + const restValue = restRow[fieldName] ?? null; + const bulkValue = bulkRow[fieldName] ?? null; + assert( + (restValue === null) === (bulkValue === null) && typeof restValue === typeof bulkValue, + `${fieldName}: REST ${typeof restValue} vs Bulk ${typeof bulkValue}.`, + ); + // Datetime formatting differs between the APIs (+0000 vs Z); compare type only. + if (fieldName !== 'CreatedDate') { + assert(restValue === bulkValue, `${fieldName}: REST and Bulk values differ.`); + } + compared++; + } + } + pass(label, `${restRows.records.length} records, ${compared} values`, typingJob.id); +} + async function main(): Promise { const org = await api.query<{ Id: string; IsSandbox: boolean }>( 'SELECT Id, IsSandbox FROM Organization LIMIT 1', @@ -129,6 +161,9 @@ async function main(): Promise { assert(queryPage.records.length <= 5, 'Bulk query exceeded its requested record limit.'); pass('Bulk Query 2.0 create, resume, and results', `${queryPage.records.length} records`, queryJob.id); + // #80: Bulk results typed from describe must match REST's JSON types and values. + await compareBulkTypesWithRest('SELECT Id, Name, NumberOfEmployees, AnnualRevenue, IsDeleted, CreatedDate FROM Account ORDER BY Id LIMIT 20'); + if (readOnly && !allowWrite) { pass('write safety guard', 'write scenarios intentionally skipped'); return; @@ -192,6 +227,44 @@ async function main(): Promise { retrySuccess.forEach(result => createdIds.add(result.sf__Id)); pass('Bulk failed-row retry', '1 corrected success', retryJob.id); + // #45 / #47: ragged columns survive, results map to input rows, and values go up verbatim. + const mixedRows: Array> = [ + { Name: `${tag} Row 0` }, + { Name: 'x'.repeat(300), Phone: '+44 20 7946 0000' }, + { Name: `${tag} Row 2`, Phone: '+44 20 7946 0001' }, + ]; + const mixedJob = await bulk.createJob({ object: 'Account', operation: 'insert' }); + await bulk.uploadJobData(mixedJob.id, bulk.recordsToCsv(mixedRows)); + await bulk.closeJob(mixedJob.id); + await completeIngest(bulk, mixedJob); + const mixed = await fetchBulkRowResults(bulk, mixedJob.id, buildBulkRowIdentity(mixedRows)); + mixed.ids.forEach(id => createdIds.add(id)); + assert(mixed.ids.length === 2 && mixed.errors.length === 1, 'Mixed Bulk job result counts were incorrect.'); + assert(mixed.errors[0].recordIndex === 1, `Bulk failure mapped to row ${mixed.errors[0].recordIndex}, expected 1.`); + assert( + [...mixed.idRecordIndexes].sort().join(',') === '0,2', + `Bulk IDs mapped to rows ${mixed.idRecordIndexes.join(',')}, expected 0 and 2.`, + ); + const row2Id = mixed.ids[mixed.idRecordIndexes.indexOf(2)]; + const row2 = await api.query<{ Name: string; Phone: string | null }>(`SELECT Name, Phone FROM Account WHERE Id = '${row2Id}'`); + assert(row2.records[0]?.Name === `${tag} Row 2`, 'The ID mapped to row 2 belongs to a different record.'); + assert(row2.records[0]?.Phone === '+44 20 7946 0001', 'Phone from a column absent in row 0 was not stored verbatim.'); + pass('Bulk per-row results, ragged columns, verbatim values', 'failure on row 1, IDs on rows 0 and 2', mixedJob.id); + + const clearJob = await bulk.createJob({ object: 'Account', operation: 'update' }); + await bulk.uploadJobData(clearJob.id, bulk.recordsToCsv([{ Id: row2Id, Phone: null }])); + await bulk.closeJob(clearJob.id); + await completeIngest(bulk, clearJob); + const cleared = await api.query<{ Phone: string | null }>(`SELECT Phone FROM Account WHERE Id = '${row2Id}'`); + assert(cleared.records[0]?.Phone === null, 'Bulk update with an explicit null did not clear Phone.'); + pass('Bulk blank-means-clear', 'explicit null cleared Phone', clearJob.id); + + await api.updateRecord('Account', row2Id, { NumberOfEmployees: 42, AnnualRevenue: 1234.5 }); + await compareBulkTypesWithRest( + `SELECT Id, Name, NumberOfEmployees, AnnualRevenue, IsDeleted, CreatedDate FROM Account WHERE Id = '${row2Id}'`, + 'Bulk query types match REST (populated number, currency, boolean)', + ); + const undo = await api.createRecord('Account', { Name: `${tag} Undo` }); assert(undo.success && undo.id, 'Undo setup insert failed.'); createdIds.add(undo.id); diff --git a/src/background/index.ts b/src/background/index.ts index 879c9b5..49dc398 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -21,9 +21,10 @@ import { MessageBus } from '../services/messaging'; import { StorageService } from '../services/storage'; import { SalesforceAuth } from '../services/salesforce/auth'; -import { SalesforceApiClient } from '../services/salesforce/api-client'; import { ApiClientFactory } from '../services/salesforce/api-client-factory'; import { BulkApiService } from '../services/salesforce/bulk-api'; +import { buildBulkRowIdentity, fetchBulkRowResults } from '../services/salesforce/bulk-results'; +import { typeBulkQueryPage } from '../services/salesforce/bulk-query-typing'; import { queryAllRecords, deriveColumns } from '../services/salesforce/queryAll'; import { captureViaOffscreen, runBulkPushViaOffscreen } from './offscreen'; import { buildUpsertSubrequests, parseUpsertCompositeResponse } from '../services/salesforce/composite-upsert'; @@ -31,7 +32,7 @@ import { DEFAULT_API_VERSION, DEFAULT_BATCH_SIZE, BULK_API_THRESHOLD, MAX_COMPOS import { generateId } from '../core/utils'; import { isSalesforceUrl } from '../core/utils'; import type { MessageResponse } from '../core/types/messaging'; -import type { SalesforceOrg } from '../core/types/salesforce'; +import type { SalesforceOrg, SObjectDescribe } from '../core/types/salesforce'; import type { PushHistoryEntry, PushResult, PushTransaction, ScheduledExport, ExportSnapshot, ScheduleInterval, ActivePush, ScheduleRunHistoryEntry } from '../core/types/storage'; import type { MigrationProject } from '../core/types/migration'; @@ -328,6 +329,20 @@ function bulkServiceFor(org: SalesforceOrg): BulkApiService { return apiClientFactory.getBulkService(org); } +/** Describe an SObject through the schema cache, populating it on a miss. */ +async function describeSObjectCached(org: SalesforceOrg, objectName: string): Promise { + const cached = await storage.getCachedSchema(org.orgId, objectName); + if (cached) return cached as SObjectDescribe; + const result = await apiClientFactory.getClient(org).describeSObject(objectName); + const uiSettingsForTtl = await storage.getUiSettings(); + const ttl = uiSettingsForTtl.schemaCacheTtlMinutes ? uiSettingsForTtl.schemaCacheTtlMinutes * 60 * 1000 : SCHEMA_CACHE_TTL; + await storage.setCachedSchema(org.orgId, objectName, result, ttl); + return result; +} + +// Queried object per Bulk query job, so each result page costs no extra status call after the first. +const bulkQueryObjects = new Map(); + messageBus.on('SF_BULK_QUERY_START', async (message, sender): Promise => { try { const { soql } = message.payload as { soql: string }; @@ -352,7 +367,16 @@ messageBus.on('SF_BULK_QUERY_STATUS', async (message, sender): Promise => { try { const { jobId, locator, maxRecords } = message.payload as { jobId: string; locator?: string; maxRecords?: number }; - const page = await bulkServiceFor(await resolveSfOrg(message.payload, sender)).getQueryResults(jobId, locator, maxRecords); + const org = await resolveSfOrg(message.payload, sender); + const service = bulkServiceFor(org); + const page = await service.getQueryResults(jobId, locator, maxRecords); + // CSV values are all strings; type them from describe so Bulk exports match REST JSON types (#80). + let queriedObject = bulkQueryObjects.get(jobId); + if (!queriedObject) { + queriedObject = await service.getQueryJobStatus(jobId).then(job => job.object).catch(() => undefined); + if (queriedObject) bulkQueryObjects.set(jobId, queriedObject); + } + page.records = await typeBulkQueryPage(page.records, queriedObject, objectName => describeSObjectCached(org, objectName)); return { success: true, data: page, requestId: message.requestId }; } catch (error) { return { success: false, error: { code: 'SF_BULK_QUERY_RESULTS_ERROR', message: error instanceof Error ? error.message : 'Bulk query results failed' }, requestId: message.requestId }; @@ -420,17 +444,7 @@ messageBus.on('SF_DESCRIBE_SOBJECT', async (message, sender): Promise = []; const successfulIds: string[] = []; + // Parallel to successfulIds: the input index of each ID. Batches finish out of order and + // upserts that update return no ID, so IDs must never be zipped positionally (#47). + const successfulIdIndexes: number[] = []; let broadcastedError = false; let cancelled = false; let checkpointWrite: Promise = Promise.resolve(); @@ -1365,12 +1382,16 @@ async function executeRestPush( processed: number; failed: number; ids: string[]; + idIndexes: number[]; errors: Array<{ recordIndex: number; message: string }>; batchLevelError?: string; }): void => { processedRecords += res.processed; failedRecords += res.failed; - if (res.ids.length) successfulIds.push(...res.ids); + if (res.ids.length) { + successfulIds.push(...res.ids); + successfulIdIndexes.push(...res.idIndexes); + } if (res.errors.length) errors.push(...res.errors); const processedCheckpoint = processedRecords; @@ -1413,6 +1434,7 @@ async function executeRestPush( processed: batch.length, failed: batch.length, ids: [], + idIndexes: [], errors: batch.map((_, i) => ({ recordIndex: start + i, message: 'Upsert requires externalIdField.' })), batchLevelError: 'Upsert requires externalIdField.', }, @@ -1437,6 +1459,7 @@ async function executeRestPush( processed: batch.length, failed: batch.length, ids: [], + idIndexes: [], errors: outErrors, }, }; @@ -1446,8 +1469,12 @@ async function executeRestPush( const parsed = parseUpsertCompositeResponse(composite, built.refToRecordIndex); const ids: string[] = []; + const idIndexes: number[] = []; for (const s of parsed.successes) { - if (s.id) ids.push(s.id); + if (s.id) { + ids.push(s.id); + idIndexes.push(s.recordIndex); + } } for (const f of parsed.failures) outErrors.push({ recordIndex: f.recordIndex, message: f.message }); @@ -1457,6 +1484,7 @@ async function executeRestPush( processed: batch.length, failed: built.skipped.length + parsed.failures.length, ids, + idIndexes, errors: outErrors, }, }; @@ -1478,6 +1506,7 @@ async function executeRestPush( } const ids: string[] = []; + const idIndexes: number[] = []; const outErrors: Array<{ recordIndex: number; message: string }> = []; let failed = 0; @@ -1497,6 +1526,7 @@ async function executeRestPush( outErrors.push({ recordIndex, message: result.errors.map(e => e.message).join('; ') }); } else if (typeof result.id === 'string' && result.id.length > 0) { ids.push(result.id); + idIndexes.push(recordIndex); } } @@ -1509,6 +1539,7 @@ async function executeRestPush( processed: expected, failed, ids, + idIndexes, errors: outErrors, }, }; @@ -1523,6 +1554,7 @@ async function executeRestPush( processed: desc.batch.length, failed: desc.batch.length, ids: [], + idIndexes: [], errors: desc.batch.map((_, i) => ({ recordIndex: desc.start + i, message: `Batch failed: ${message}` })), batchLevelError: message, }, @@ -1590,6 +1622,7 @@ async function executeRestPush( objectName: payload.objectName, operation: payload.operation, ids: successfulIds, + idRecordIndexes: successfulIdIndexes, capturedAt: completedAt, failedRecords: failedRecordData.length > 0 ? failedRecordData : undefined, }; @@ -1618,6 +1651,8 @@ async function executeRestPush( failedRecords, status: cancelled ? 'cancelled' : 'complete', errors: errors.length > 0 ? errors : undefined, + ids: successfulIds, + idRecordIndexes: successfulIdIndexes, }); await storage.updateActivePush(pushId, { status: cancelled ? 'cancelled' : 'complete', @@ -1681,6 +1716,8 @@ async function executeBulkPush( }, { once: true }); const csvData = bulkApi.recordsToCsv(payload.records); + // Fingerprints of the uploaded rows let result rows be mapped back to input indices (#47). + const rowIdentity = buildBulkRowIdentity(payload.records); await bulkApi.uploadJobData(job.id, csvData); await bulkApi.closeJob(job.id); @@ -1699,6 +1736,7 @@ async function executeBulkPush( totalRecords: payload.records.length, startedAt: ctx.startedAt, externalIdField: payload.externalIdField, + rowIdentity, }); return; } catch { @@ -1721,18 +1759,10 @@ async function executeBulkPush( }); }, ctx.abortSignal); + const rowResults = completedJob.state === 'JobComplete' + ? await fetchBulkRowResults(bulkApi, job.id, rowIdentity) + : { ids: [], idRecordIndexes: [], errors: [] }; const completedAt = Date.now(); - const successfulIds: string[] = []; - if (completedJob.state === 'JobComplete') { - try { - const successful = await bulkApi.getSuccessfulResults(job.id); - for (const row of successful) { - if (typeof row.sf__Id === 'string' && row.sf__Id.length > 0) successfulIds.push(row.sf__Id); - } - } catch { - // Ignore: results endpoint may fail in some orgs/permissions; push itself can still be complete. - } - } const historyEntry: PushHistoryEntry = { id: pushId, @@ -1746,6 +1776,7 @@ async function executeBulkPush( failureCount: completedJob.numberRecordsFailed, startedAt: ctx.startedAt, completedAt, + errors: rowResults.errors.length > 0 ? rowResults.errors : undefined, }; await storage.addPushHistory(historyEntry); @@ -1755,7 +1786,8 @@ async function executeBulkPush( orgId: org.orgId, objectName: payload.objectName, operation: payload.operation, - ids: successfulIds, + ids: rowResults.ids, + idRecordIndexes: rowResults.idRecordIndexes, capturedAt: completedAt, }; await storage.setPushResult(pushResult); @@ -1766,6 +1798,9 @@ async function executeBulkPush( processedRecords: completedJob.numberRecordsProcessed, failedRecords: completedJob.numberRecordsFailed, status: completedJob.state === 'JobComplete' ? 'complete' : 'error', + errors: rowResults.errors.length > 0 ? rowResults.errors : undefined, + ids: rowResults.ids, + idRecordIndexes: rowResults.idRecordIndexes, }); await storage.updateActivePush(pushId, { status: completedJob.state === 'JobComplete' ? 'complete' : 'error', @@ -1879,14 +1914,10 @@ async function resumeBulkPush(active: ActivePush, org: SalesforceOrg, abortSigna }, abortSignal); if (completed.state !== 'JobComplete') throw new Error(`Salesforce Bulk job ended in ${completed.state}.`); - const successfulIds: string[] = []; - try { - for (const row of await bulkApi.getSuccessfulResults(active.bulkJobId)) { - if (row.sf__Id) successfulIds.push(row.sf__Id); - } - } catch { - // Completion remains valid if detailed result rows are unavailable. - } + // The original records are gone after eviction, so result rows cannot be mapped to input + // indices (they report -1), but IDs and failure messages are still surfaced. + const rowResults = await fetchBulkRowResults(bulkApi, active.bulkJobId, undefined); + const successfulIds = rowResults.ids; const completedAt = Date.now(); const existingHistory = await storage.getPushHistory(); if (!existingHistory.some(entry => entry.id === active.id)) { @@ -1901,6 +1932,7 @@ async function resumeBulkPush(active: ActivePush, org: SalesforceOrg, abortSigna failureCount: completed.numberRecordsFailed, startedAt: active.startedAt, completedAt, + errors: rowResults.errors.length > 0 ? rowResults.errors : undefined, }); } await storage.setPushResult({ @@ -1909,6 +1941,7 @@ async function resumeBulkPush(active: ActivePush, org: SalesforceOrg, abortSigna objectName: active.objectName, operation: active.operation, ids: successfulIds, + idRecordIndexes: rowResults.idRecordIndexes, capturedAt: completedAt, }); if (active.operation === 'insert' && successfulIds.length > 0) { @@ -1937,6 +1970,9 @@ async function resumeBulkPush(active: ActivePush, org: SalesforceOrg, abortSigna processedRecords: completed.numberRecordsProcessed, failedRecords: completed.numberRecordsFailed, status: 'complete', + errors: rowResults.errors.length > 0 ? rowResults.errors : undefined, + ids: successfulIds, + idRecordIndexes: rowResults.idRecordIndexes, }); } @@ -2057,6 +2093,7 @@ messageBus.on('DATA_PUSH_RESULT_GET', async (message): Promise objectName: result.objectName, operation: result.operation, ids: result.ids, + idRecordIndexes: result.idRecordIndexes, capturedAt: result.capturedAt, failedRecords: result.failedRecords, }, @@ -2261,7 +2298,8 @@ function computeNextRunAt(interval: ScheduleInterval, timeZone?: string, afterMs }); const parts = localFmt.formatToParts(new Date(afterMs)); const get = (t: string) => Number(parts.find(p => p.type === t)?.value ?? 0); - let y = get('year'), mo = get('month') - 1, d = get('day'); + const y = get('year'), mo = get('month') - 1; + let d = get('day'); const h = get('hour'), mi = get('minute'), sec = get('second'); // Advance by N days in the target zone's calendar diff --git a/src/content/index.ts b/src/content/index.ts index d9c68a6..2fe17c0 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -136,8 +136,7 @@ async function reportOrgDetection(): Promise { try { await messageBus.send('ORG_DETECT', orgInfo); - } catch (error) { - // Background may not be ready yet, fail silently + } catch { // Background may not be ready yet; ignore silently } } diff --git a/src/core/types/messaging.ts b/src/core/types/messaging.ts index f44fb1d..307e4c2 100644 --- a/src/core/types/messaging.ts +++ b/src/core/types/messaging.ts @@ -173,7 +173,12 @@ export interface DataPushProgressPayload { processedRecords?: number; failedRecords?: number; status?: 'processing' | 'complete' | 'error' | 'cancelled'; + /** Per-row failures; `recordIndex` is the position in the pushed `records` array (-1 = unidentified row). */ errors?: Array<{ recordIndex: number; message: string }>; + /** Record IDs returned by Salesforce on completion. */ + ids?: string[]; + /** `idRecordIndexes[i]` is the pushed-record index that produced `ids[i]` (-1 = unidentified row). */ + idRecordIndexes?: number[]; /** Single error message for DATA_PUSH_ERROR broadcasts. */ error?: string; } @@ -195,6 +200,8 @@ export interface DataPushResultGetResponse { objectName: string; operation: 'insert' | 'update' | 'upsert' | 'delete'; ids: string[]; + /** `idRecordIndexes[i]` is the input record index that produced `ids[i]` (-1 = unidentified row). */ + idRecordIndexes?: number[]; capturedAt: number; /** Per-record failure metadata; indices correspond to input record positions. */ failedRecords?: Array<{ index: number; record: Record; error: string }>; @@ -553,6 +560,7 @@ export interface PayloadMap { MIGRATION_REPORTS_GET: MigrationReportGetPayload; MIGRATION_REPORTS_SAVE: MigrationReportSavePayload; MIGRATION_REPORTS_DELETE: MigrationReportDeletePayload; + OFFSCREEN_TOKEN_REFRESH: { orgId?: string; instanceUrl?: string }; } /** Base message shape — payload type is derived from the message type via PayloadMap. */ diff --git a/src/core/types/offscreen.ts b/src/core/types/offscreen.ts index 0b5da1b..7635f3b 100644 --- a/src/core/types/offscreen.ts +++ b/src/core/types/offscreen.ts @@ -38,6 +38,8 @@ export interface OffscreenBulkPushPayload { totalRecords: number; startedAt: number; externalIdField?: string; + /** Input-row fingerprints used to map Bulk result rows back to input indices (#47). */ + rowIdentity?: { headers: string[]; fingerprints: string[] }; } export interface OffscreenBulkPushRequest { diff --git a/src/core/types/salesforce.ts b/src/core/types/salesforce.ts index d1b43bc..c3348ae 100644 --- a/src/core/types/salesforce.ts +++ b/src/core/types/salesforce.ts @@ -65,6 +65,8 @@ export interface SObjectField { referenceTo?: string[]; relationshipName?: string | null; externalId: boolean; + /** True when the field can identify a record in a relationship lookup (describe `idLookup`). */ + idLookup?: boolean; unique: boolean; } @@ -159,6 +161,8 @@ export type BulkJobState = export interface BulkQueryJob { id: string; operation: 'query' | 'queryAll'; + /** API name of the queried (FROM) object. */ + object?: string; state: 'UploadComplete' | 'InProgress' | 'Aborted' | 'JobComplete' | 'Failed'; numberRecordsProcessed: number; retries?: number; diff --git a/src/core/types/storage.ts b/src/core/types/storage.ts index 207a74c..177d238 100644 --- a/src/core/types/storage.ts +++ b/src/core/types/storage.ts @@ -198,6 +198,12 @@ export interface PushResult { objectName: string; operation: 'insert' | 'update' | 'upsert' | 'delete'; ids: string[]; + /** + * `idRecordIndexes[i]` is the input record index that produced `ids[i]` (-1 = unidentified row). + * Results can arrive out of input order and successes need not return an ID, so never zip `ids` + * positionally against the input. + */ + idRecordIndexes?: number[]; capturedAt: number; /** Failed records stored for retry */ failedRecords?: Array<{ index: number; record: Record; error: string }>; diff --git a/src/data/mappers/index.ts b/src/data/mappers/index.ts index d72836b..b8eaf18 100644 --- a/src/data/mappers/index.ts +++ b/src/data/mappers/index.ts @@ -1,4 +1,4 @@ -/** +/** * Data mapping and transformation engine. * Maps source data fields to Salesforce target fields with transformations. * @@ -14,6 +14,12 @@ import type { SObjectField } from '../../core/types/salesforce'; export interface MappingResult { mappedRecords: Record[]; + /** + * `sourceIndexes[i]` is the source-record index that produced `mappedRecords[i]`. Rows with + * mapping errors are dropped, so push results (indexed by mapped position) must be translated + * through this before they are applied to source rows (#46). + */ + sourceIndexes: number[]; errors: MappingError[]; } @@ -34,7 +40,7 @@ export type MappingMatchKind = /** A scored suggestion linking one source header to one Salesforce field. */ export interface MappingSuggestion extends FieldMapping { - /** 0–1 confidence. 1 = exact API-name match, lower = looser/fuzzy. */ + /** 0–1 confidence. 1 = exact API-name match, lower = looser/fuzzy. */ confidence: number; /** What the match was based on (for surfacing "auto" vs "guess" in the UI). */ matchedOn: MappingMatchKind; @@ -103,6 +109,7 @@ export class DataMapper { ): MappingResult { // Time: O(N*M). Data: returns mappedRecords (good) + errors (per-record/per-field). const mappedRecords: Record[] = []; + const sourceIndexes: number[] = []; const errors: MappingError[] = []; for (let i = 0; i < sourceRecords.length; i++) { @@ -153,10 +160,11 @@ export class DataMapper { if (!hasError) { mappedRecords.push(mapped); + sourceIndexes.push(i); } } - return { mappedRecords, errors }; + return { mappedRecords, sourceIndexes, errors }; } /** @@ -219,7 +227,7 @@ export class DataMapper { /** * Auto-generate field mappings by matching source field names to Salesforce * field names and labels. Conservative: only high-confidence matches (exact - * or normalized on name/label) are returned — fuzzy guesses are excluded. + * or normalized on name/label) are returned — fuzzy guesses are excluded. */ autoMapFields( sourceFields: string[], diff --git a/src/data/validators/index.ts b/src/data/validators/index.ts index 20273b9..c09afa6 100644 --- a/src/data/validators/index.ts +++ b/src/data/validators/index.ts @@ -5,6 +5,15 @@ * * Complexity: * - `validateRecords` is O(N * K) where N is records and K is keys per record. + * + * Reference lookups: a mapped key may be a relationship name (for example + * `Account` or `Parent__r`) holding a nested `{ MatchField: value }` object, + * as produced by DataMapper for `externalId` / `relatedField` lookups. Those + * keys are resolved to their reference field (`AccountId`, `Parent__c`) via + * `relationshipName`, and the nested match field is checked for shape. When + * describe metadata for the referenced object is supplied via + * `options.relatedFields`, the match field must also exist there and be an + * External ID or idLookup field. */ import type { SObjectField, SalesforceFieldType } from '../../core/types/salesforce'; @@ -15,6 +24,23 @@ export interface ValidationResult { errors: FieldValidationError[]; } +export interface ValidationOptions { + /** Describe fields of referenced objects, keyed by SObject API name. */ + relatedFields?: Record; +} + +/** + * Relationship name for a reference field. Mirrors the import UI's default: + * describe `relationshipName`, else `Foo__c` -> `Foo__r`, else `FooId` -> `Foo`. + */ +function relationshipNameOf(field: SObjectField): string | null { + if (field.type !== 'reference') return null; + if (field.relationshipName) return field.relationshipName; + if (field.name.endsWith('__c')) return field.name.replace(/__c$/, '__r'); + if (field.name.endsWith('Id') && field.name.length > 2) return field.name.replace(/Id$/, ''); + return null; +} + /** * DataValidator checks records against Salesforce field metadata. */ @@ -26,14 +52,20 @@ export class DataValidator { records: Record[], fields: SObjectField[], operation: 'insert' | 'update' | 'upsert' | 'delete', + options: ValidationOptions = {}, ): ValidationResult { // Time: O(N*K). Data: returns a flat list of `FieldValidationError` entries. const fieldMap = new Map(fields.map(f => [f.name, f])); + const relationshipMap = new Map(); + for (const f of fields) { + const rel = relationshipNameOf(f); + if (rel && !fieldMap.has(rel)) relationshipMap.set(rel, f); + } const allErrors: FieldValidationError[] = []; for (let i = 0; i < records.length; i++) { const record = records[i]; - const recordErrors = this.validateRecord(record, fieldMap, operation, i); + const recordErrors = this.validateRecord(record, fieldMap, relationshipMap, operation, i, options); allErrors.push(...recordErrors); } @@ -49,8 +81,10 @@ export class DataValidator { private validateRecord( record: Record, fieldMap: Map, + relationshipMap: Map, operation: string, recordIndex: number, + options: ValidationOptions, ): FieldValidationError[] { const errors: FieldValidationError[] = []; @@ -59,6 +93,11 @@ export class DataValidator { if (key === 'Id' || key === 'id') continue; const field = fieldMap.get(key); if (!field) { + const refField = relationshipMap.get(key); + if (refField) { + errors.push(...this.validateRelationshipValue(key, record[key], refField, operation, recordIndex, options)); + continue; + } errors.push({ field: key, message: `Record ${recordIndex}: Unknown field "${key}"`, @@ -92,7 +131,9 @@ export class DataValidator { // Check required fields for insert if (operation === 'insert') { for (const [fieldName, field] of fieldMap) { - if (field.required && field.createable && !field.defaultValue && !(fieldName in record)) { + const rel = relationshipNameOf(field); + const suppliedViaRelationship = rel !== null && relationshipMap.get(rel) === field && rel in record; + if (field.required && field.createable && !field.defaultValue && !(fieldName in record) && !suppliedViaRelationship) { errors.push({ field: fieldName, message: `Record ${recordIndex}: Required field "${fieldName}" is missing`, @@ -104,6 +145,62 @@ export class DataValidator { return errors; } + /** + * Validate a relationship key (e.g. `Account: { External_Key__c: 'A-1' }`) + * against its reference field (`AccountId`). + */ + private validateRelationshipValue( + key: string, + value: unknown, + refField: SObjectField, + operation: string, + recordIndex: number, + options: ValidationOptions, + ): FieldValidationError[] { + const prefix = `Record ${recordIndex}:`; + + if (operation === 'insert' && !refField.createable) { + return [{ field: key, message: `${prefix} Field "${refField.name}" (via "${key}") is not createable` }]; + } + if ((operation === 'update' || operation === 'upsert') && !refField.updateable) { + return [{ field: key, message: `${prefix} Field "${refField.name}" (via "${key}") is not updateable` }]; + } + + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return [{ field: key, message: `${prefix} Relationship "${key}" must be an object like { MatchField: value }`, value }]; + } + const entries = Object.entries(value as Record).filter(([k]) => k !== 'attributes'); + if (entries.length !== 1) { + return [{ field: key, message: `${prefix} Relationship "${key}" must specify exactly one match field`, value }]; + } + + const [matchField, matchValue] = entries[0]; + const isScalar = typeof matchValue === 'string' || typeof matchValue === 'number' || typeof matchValue === 'boolean'; + if (!isScalar || String(matchValue).trim() === '') { + return [{ field: key, message: `${prefix} Relationship "${key}.${matchField}" needs a non-empty value`, value }]; + } + + // Only checkable when the caller supplies describe metadata for the + // referenced object; polymorphic references are left to Salesforce. + const targets = refField.referenceTo ?? []; + const relatedFields = targets.length === 1 ? options.relatedFields?.[targets[0]] : undefined; + if (relatedFields) { + const match = relatedFields.find(f => f.name === matchField); + if (!match) { + return [{ field: key, message: `${prefix} Unknown field "${matchField}" on ${targets[0]} (via "${key}")`, value }]; + } + if (!match.externalId && !match.idLookup) { + return [{ + field: key, + message: `${prefix} Field "${targets[0]}.${matchField}" is not an External ID or idLookup field and cannot be used to match "${key}"`, + value, + }]; + } + } + + return []; + } + /** * Validate a single field value against its metadata. */ @@ -217,9 +314,10 @@ export function assertValid( records: Record[], fields: SObjectField[], operation: 'insert' | 'update' | 'upsert' | 'delete', + options: ValidationOptions = {}, ): void { const validator = new DataValidator(); - const result = validator.validateRecords(records, fields, operation); + const result = validator.validateRecords(records, fields, operation, options); if (!result.valid) { throw new ValidationError( `Validation failed with ${result.errors.length} error(s)`, diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts index 0de7788..c7c60ad 100644 --- a/src/offscreen/index.ts +++ b/src/offscreen/index.ts @@ -17,6 +17,7 @@ import type { OffscreenCaptureRequest, OffscreenCaptureResponse } from '../core/ import type { OffscreenBulkPushRequest, OffscreenBulkPushResponse } from '../core/types/offscreen'; import type { OffscreenTokenRefreshResponse } from '../core/types/offscreen'; import { BulkApiService } from '../services/salesforce/bulk-api'; +import { fetchBulkRowResults } from '../services/salesforce/bulk-results'; import { StorageService } from '../services/storage'; import { generateId } from '../core/utils'; import { UNDO_TTL_MS } from '../core/constants'; @@ -82,12 +83,8 @@ export async function runBulkPush(request: OffscreenBulkPushRequest): Promise entry.id === p.pushId)) { @@ -97,11 +94,12 @@ export async function runBulkPush(request: OffscreenBulkPushRequest): Promise 0 ? errors : undefined, }); } await storage.setPushResult({ pushId: p.pushId, orgId: p.orgId, objectName: p.objectName, - operation: p.operation, ids, capturedAt: completedAt, + operation: p.operation, ids, idRecordIndexes, capturedAt: completedAt, }); if (p.operation === 'insert' && ids.length > 0) { await storage.addPushTransaction({ @@ -119,6 +117,7 @@ export async function runBulkPush(request: OffscreenBulkPushRequest): Promise 0 ? errors : undefined, ids, idRecordIndexes, }); } diff --git a/src/services/salesforce/bulk-api.ts b/src/services/salesforce/bulk-api.ts index f298662..0ae6253 100644 --- a/src/services/salesforce/bulk-api.ts +++ b/src/services/salesforce/bulk-api.ts @@ -47,6 +47,35 @@ export interface BulkQueryResultPage { numberOfRecords: number; } +/** Bulk API 2.0 sets a field to null only for this literal; an empty cell leaves it unchanged. */ +export const BULK_NULL_VALUE = '#N/A'; + +/** + * Ingest CSV header row: the union of keys across all records, in first-seen order (#45). + * The mapper omits blank-ignored keys per record, so row 0 alone can miss whole columns. + */ +export function bulkCsvHeaders(records: Record[]): string[] { + const headers = new Set(); + for (const record of records) { + for (const key of Object.keys(record)) headers.add(key); + } + return Array.from(headers); +} + +/** + * Unquoted ingest CSV cell for one field. Missing keys stay blank (Salesforce leaves the field + * untouched); an explicit `null` ("blank means clear") becomes `#N/A` so Bulk clears it like REST. + */ +export function bulkCsvCellValue(record: Record, header: string): string { + const val = record[header]; + if (val === undefined) return ''; + if (val === null) return BULK_NULL_VALUE; + // Sent verbatim. Formula-injection neutralisation (#66) applies to files opened in a spreadsheet + // (exports), not API payloads: a `'` prefix here would be stored in Salesforce or make values + // such as -5, +44 20 7946 0000 or @handle invalid. + return String(val); +} + /** * BulkApiService manages Bulk API 2.0 ingest jobs. */ @@ -182,6 +211,9 @@ export class BulkApiService { }); const csvText = await response.text(); + if (!response.ok) { + throw new SalesforceApiError(`Failed to retrieve bulk job results: ${csvText}`, response.status, undefined, { jobId }); + } return this.parseCsv(csvText); } @@ -198,6 +230,9 @@ export class BulkApiService { }); const csvText = await response.text(); + if (!response.ok) { + throw new SalesforceApiError(`Failed to retrieve bulk job results: ${csvText}`, response.status, undefined, { jobId }); + } return this.parseCsv(csvText); } @@ -244,7 +279,8 @@ export class BulkApiService { header: true, delimiter: ',', skipEmptyLines: true, - dynamicTyping: true, + // No dynamicTyping: it coerces Text values ("00123" -> 123, long numbers lose precision, + // "true" -> boolean). Values are typed from field describe metadata instead (bulk-query-typing.ts). transformHeader: header => header.trim(), }); if (parsed.errors.length > 0) { @@ -275,19 +311,13 @@ export class BulkApiService { recordsToCsv(records: Record[]): string { if (records.length === 0) return ''; - const headers = Object.keys(records[0]); + const headers = bulkCsvHeaders(records); const lines = [headers.join(',')]; for (const record of records) { const values = headers.map(h => { - const val = record[h]; - if (val === null || val === undefined) return ''; - let str = String(val); - // Security (#66): neutralize formula-injection payloads in upload CSV. - if (/^[=+\-@\t\r]/.test(str)) { - str = `'${str}`; - } - if (str.includes(',') || str.includes('"') || str.includes('\n')) { + const str = bulkCsvCellValue(record, h); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return `"${str.replace(/"/g, '""')}"`; } return str; diff --git a/src/services/salesforce/bulk-query-typing.ts b/src/services/salesforce/bulk-query-typing.ts new file mode 100644 index 0000000..2b1b43f --- /dev/null +++ b/src/services/salesforce/bulk-query-typing.ts @@ -0,0 +1,137 @@ +/** + * Types Bulk API 2.0 query results from field describe metadata (#80). + * + * Why: + * - Bulk query results arrive as CSV, so every value is a string, while the REST query API returns + * typed JSON. The same query must export the same JSON types in both modes. + * - Guessing types from the CSV text corrupts data (Text "02134" -> 2134, 18-digit external IDs lose + * precision, Text "true" -> true), so only the queried object's describe decides the type. + * + * Rules (mirroring what REST returns): + * - Empty cell -> `null` (Bulk CSV writes null as an empty cell; Salesforce never stores "" for a field). + * - `boolean` -> true/false. + * - `int`/`double`/`currency`/`percent` -> number, only when the decimal value is exactly representable + * as a JS number; otherwise the string is kept rather than silently losing precision. + * - Everything else (Id, reference, text, picklist, date, datetime, time, ...) stays a string, as REST + * returns dates and datetimes as ISO strings. + * - Relationship columns (`Account.Owner.Name`) are resolved through each lookup's `referenceTo` + * describe. Polymorphic lookups (several `referenceTo` targets, e.g. `What.Name`) and columns that + * cannot be resolved stay strings. + * + * Complexity: O(H * D) to resolve H headers through relationship depth D, then O(N * H) per page. + */ + +import type { SObjectDescribe, SObjectField } from '../../core/types/salesforce'; + +export type DescribeLookup = (objectName: string) => Promise; + +/** Describe field type per CSV header; `undefined` when the column could not be resolved. */ +export type BulkFieldTypeMap = Record; + +const INTEGER_TYPES = new Set(['int', 'long']); +const DECIMAL_TYPES = new Set(['double', 'currency', 'percent']); + +/** Doubles round-trip any decimal with at most 15 significant digits (DBL_DIG). */ +const MAX_EXACT_SIGNIFICANT_DIGITS = 15; +const DECIMAL_PATTERN = /^[+-]?(\d*)(?:\.(\d*))?(?:[eE][+-]?\d+)?$/; + +function toExactNumber(raw: string, integer: boolean): number | string { + const text = raw.trim(); + if (integer) { + if (!/^[+-]?\d+$/.test(text)) return raw; + const value = Number(text); + return Number.isSafeInteger(value) ? value : raw; + } + const match = DECIMAL_PATTERN.exec(text); + if (!match || `${match[1]}${match[2] ?? ''}` === '') return raw; + const digits = `${match[1]}${match[2] ?? ''}`.replace(/^0+/, '').replace(/0+$/, ''); + if (digits.length > MAX_EXACT_SIGNIFICANT_DIGITS) return raw; + const value = Number(text); + return Number.isFinite(value) ? value : raw; +} + +/** Convert one Bulk CSV cell to the JSON type REST would return for a field of `fieldType`. */ +export function typeBulkQueryValue(raw: unknown, fieldType: string | undefined): unknown { + if (typeof raw !== 'string') return raw; + if (raw === '') return null; + if (!fieldType) return raw; + if (fieldType === 'boolean') { + if (raw === 'true') return true; + if (raw === 'false') return false; + return raw; + } + if (INTEGER_TYPES.has(fieldType)) return toExactNumber(raw, true); + if (DECIMAL_TYPES.has(fieldType)) return toExactNumber(raw, false); + return raw; +} + +function findField(describe: SObjectDescribe, name: string): SObjectField | undefined { + const lower = name.toLowerCase(); + return describe.fields.find(f => f.name.toLowerCase() === lower); +} + +function findRelationship(describe: SObjectDescribe, relationshipName: string): SObjectField | undefined { + const lower = relationshipName.toLowerCase(); + return describe.fields.find(f => f.relationshipName?.toLowerCase() === lower); +} + +/** + * Resolve the describe type of every CSV header, following relationship paths. + * Describe failures leave the affected columns unresolved (strings) instead of failing the page. + */ +export async function resolveBulkFieldTypes( + rootObject: string, + headers: string[], + describe: DescribeLookup, +): Promise { + const describes = new Map>(); + const load = (objectName: string): Promise => { + const key = objectName.toLowerCase(); + let pending = describes.get(key); + if (!pending) { + pending = describe(objectName).catch(() => null); + describes.set(key, pending); + } + return pending; + }; + + const types: BulkFieldTypeMap = {}; + for (const header of headers) { + const path = header.split('.'); + let current = await load(rootObject); + for (const relationship of path.slice(0, -1)) { + if (!current) break; + const lookup = findRelationship(current, relationship); + const targets = lookup?.referenceTo ?? []; + current = targets.length === 1 ? await load(targets[0]) : null; + } + types[header] = current ? findField(current, path[path.length - 1])?.type : undefined; + } + return types; +} + +/** Apply `typeBulkQueryValue` to every cell of a parsed Bulk query page. */ +export function typeBulkQueryRecords( + records: Array>, + types: BulkFieldTypeMap, +): Array> { + return records.map(record => { + const typed: Record = {}; + for (const [key, value] of Object.entries(record)) { + typed[key] = typeBulkQueryValue(value, types[key]); + } + return typed; + }); +} + +/** Type one parsed page: resolve the page's headers against `rootObject`'s describe and convert values. */ +export async function typeBulkQueryPage( + records: Array>, + rootObject: string | undefined, + describe: DescribeLookup, +): Promise>> { + if (records.length === 0) return records; + const headers = Object.keys(records[0]); + const types = rootObject ? await resolveBulkFieldTypes(rootObject, headers, describe) : {}; + return typeBulkQueryRecords(records, types); +} diff --git a/src/services/salesforce/bulk-results.ts b/src/services/salesforce/bulk-results.ts new file mode 100644 index 0000000..d738817 --- /dev/null +++ b/src/services/salesforce/bulk-results.ts @@ -0,0 +1,135 @@ +/** + * Bulk API 2.0 per-row results mapped back to input-record indices (#47). + * + * Why fingerprints instead of position or a row-number column: + * - Bulk API 2.0 rejects CSV columns that are not fields, so a synthetic row key cannot be uploaded. + * - Results are split into successfulResults / failedResults and Salesforce does not promise upload + * order within either file (internal batches run in parallel), so zipping them against the input + * attaches IDs and errors to the wrong rows. + * - Each result row echoes the uploaded columns. Fingerprinting the cells exactly as `recordsToCsv` + * serialized them identifies the input row. Identical duplicate rows are claimed in input order, + * which is indistinguishable for them anyway. Rows that cannot be identified get index -1 rather + * than a guessed position. + * + * Fingerprints are hashed so the identity handed to the offscreen document stays small for large jobs. + * + * Complexity: O(N * K) to fingerprint N rows of K columns; matching is O(R) map lookups for R results. + */ + +import { BULK_NULL_VALUE, bulkCsvCellValue, bulkCsvHeaders } from './bulk-api'; +import type { BulkApiService, BulkJobResult } from './bulk-api'; + +/** Input-row identity for one ingest job: the uploaded header row plus one fingerprint per record. */ +export interface BulkRowIdentity { + headers: string[]; + /** `fingerprints[i]` identifies input record `i`. */ + fingerprints: string[]; +} + +export interface BulkRowResults { + /** Every record ID from successfulResults (undo needs all of them, identified or not). */ + ids: string[]; + /** `idRecordIndexes[i]` is the input index that produced `ids[i]`; -1 when it could not be identified. */ + idRecordIndexes: number[]; + /** One entry per failed row; `recordIndex` is -1 when the row could not be identified. */ + errors: Array<{ recordIndex: number; message: string }>; +} + +/** 53-bit string hash (cyrb53). Two seeds are combined so distinct rows effectively never collide. */ +function cyrb53(str: string, seed: number): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +/** + * Normalize one cell so the uploaded value and Salesforce's echo compare equal even if the echo + * trims whitespace, rewrites line endings, or blanks the `#N/A` null marker. + */ +function normalizeCell(value: string): string { + const trimmed = value.replace(/\r\n?/g, '\n').trim(); + return trimmed === BULK_NULL_VALUE ? '' : trimmed; +} + +function fingerprint(cells: string[]): string { + const joined = cells.map(normalizeCell).join('\u001f'); + return `${cyrb53(joined, 1).toString(36)}.${cyrb53(joined, 2).toString(36)}`; +} + +/** Fingerprint input records using the same header union and cell serialization as `recordsToCsv`. */ +export function buildBulkRowIdentity(records: Record[]): BulkRowIdentity { + const headers = bulkCsvHeaders(records); + return { + headers, + fingerprints: records.map(record => fingerprint(headers.map(h => bulkCsvCellValue(record, h)))), + }; +} + +/** Map successfulResults / failedResults rows back to input indices. */ +export function mapBulkResultsToInput( + identity: BulkRowIdentity | undefined, + successful: BulkJobResult[], + failed: BulkJobResult[], +): BulkRowResults { + // Fingerprint -> unclaimed input indices, in input order. + const unclaimed = new Map(); + identity?.fingerprints.forEach((fp, index) => { + const queue = unclaimed.get(fp); + if (queue) queue.push(index); + else unclaimed.set(fp, [index]); + }); + + const claim = (rows: BulkJobResult[]): (row: BulkJobResult) => number => { + if (!identity || rows.length === 0) return () => -1; + // Tolerate header-case differences in the echoed columns. + const echoed = new Map(Object.keys(rows[0]).map(key => [key.toLowerCase(), key])); + const columns = identity.headers.map(h => (h in rows[0] ? h : echoed.get(h.toLowerCase()))); + return row => { + const queue = unclaimed.get(fingerprint(columns.map(c => (c === undefined ? '' : row[c] ?? '')))); + return queue && queue.length > 0 ? queue.shift()! : -1; + }; + }; + + const ids: string[] = []; + const idRecordIndexes: number[] = []; + const claimSuccess = claim(successful); + for (const row of successful) { + const recordIndex = claimSuccess(row); + if (!row.sf__Id) continue; + ids.push(row.sf__Id); + idRecordIndexes.push(recordIndex); + } + + const errors: Array<{ recordIndex: number; message: string }> = []; + const claimFailure = claim(failed); + for (const row of failed) { + errors.push({ recordIndex: claimFailure(row), message: row.sf__Error || 'Bulk API reported a failure without a message.' }); + } + + return { ids, idRecordIndexes, errors }; +} + +/** + * Fetch both Bulk result files for a completed job and map them to input indices. + * A result file that cannot be read contributes nothing; the job summary stays authoritative. + */ +export async function fetchBulkRowResults( + bulk: Pick, + jobId: string, + identity: BulkRowIdentity | undefined, +): Promise { + const [successful, failed] = await Promise.all([ + bulk.getSuccessfulResults(jobId).catch(() => [] as BulkJobResult[]), + bulk.getFailedResults(jobId).catch(() => [] as BulkJobResult[]), + ]); + return mapBulkResultsToInput(identity, successful, failed); +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 49b4f5b..0240298 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -16,7 +16,7 @@ */ import { StorageError } from '../../core/errors'; -import { STORAGE_KEYS, MAX_PUSH_HISTORY, MAX_UNDO_ENTRIES, SCHEMA_CACHE_TTL } from '../../core/constants'; +import { STORAGE_KEYS, MAX_PUSH_HISTORY, MAX_UNDO_ENTRIES } from '../../core/constants'; import type { LocalStorageSchema, SessionStorageSchema, PushHistoryEntry, PushResult, ActivePush, SavedQuery, QueryFolder, UiSettings, DataTemplate, PushTransaction, Pipeline, QualityRuleSet, OnboardingProgress } from '../../core/types/storage'; import type { MigrationProject, IdMap, IdMapEntry, MigrationTemplate, MigrationSummaryReport } from '../../core/types/migration'; import type { SalesforceOrg } from '../../core/types/salesforce'; @@ -36,6 +36,19 @@ const QUOTA_EVICTION_THRESHOLD = 0.85; /** Target fraction of quota to free during emergency eviction. */ const QUOTA_EVICTION_TARGET = 0.7; +/** Remove terminal pushes older than ACTIVE_PUSH_PRUNE_AGE_MS from `all` in place; returns count. */ +function removeStaleTerminalPushes(all: Record): number { + const cutoff = Date.now() - ACTIVE_PUSH_PRUNE_AGE_MS; + let pruned = 0; + for (const [id, push] of Object.entries(all)) { + if (TERMINAL_PUSH_STATUSES.has(push.status) && (push.updatedAt ?? push.startedAt) < cutoff) { + delete all[id]; + pruned++; + } + } + return pruned; +} + /** * StorageService abstracts chrome.storage operations with type safety. */ @@ -669,14 +682,7 @@ export class StorageService { */ async pruneTerminalPushes(): Promise { const all = await this.getActivePushMap(); - const cutoff = Date.now() - ACTIVE_PUSH_PRUNE_AGE_MS; - let pruned = 0; - for (const [id, push] of Object.entries(all)) { - if (TERMINAL_PUSH_STATUSES.has(push.status) && (push.updatedAt ?? push.startedAt) < cutoff) { - delete all[id]; - pruned++; - } - } + const pruned = removeStaleTerminalPushes(all); if (pruned > 0) { await this.setLocal(STORAGE_KEYS.ACTIVE_PUSHES, all); } @@ -827,10 +833,15 @@ export class StorageService { private async setLocal(key: string, value: unknown): Promise { try { - // Pre-write quota check: if usage exceeds threshold, attempt eviction. - const usage = await this.getStorageUsage(); - if (usage.bytesInUse > usage.quota * QUOTA_EVICTION_THRESHOLD) { - await this.evictForQuota(usage.quota); + // Pre-write quota check: if usage exceeds threshold, attempt eviction. Best-effort only: + // a failing usage API or eviction step must never block the caller's write. + try { + const usage = await this.getStorageUsage(); + if (usage.bytesInUse > usage.quota * QUOTA_EVICTION_THRESHOLD) { + await this.evictForQuota(usage.quota); + } + } catch { + // Ignore; the write below (and its quota-error retry) still runs. } await chrome.storage.local.set({ [key]: value }); } catch (error) { @@ -855,6 +866,9 @@ export class StorageService { /** * Emergency eviction: clear schema cache entries and old push history until * usage drops below QUOTA_EVICTION_TARGET * quota. + * + * Writes here go straight to chrome.storage.local, never through setLocal: setLocal runs this + * eviction first, so re-entering it while usage is still high would recurse indefinitely. */ private async evictForQuota(quota: number): Promise { const target = quota * QUOTA_EVICTION_TARGET; @@ -887,7 +901,10 @@ export class StorageService { if (usage <= target) return; // 3. Prune terminal active pushes. - await this.pruneTerminalPushes(); + const pushes = await this.getLocal>(STORAGE_KEYS.ACTIVE_PUSHES) ?? {}; + if (removeStaleTerminalPushes(pushes) > 0) { + await chrome.storage.local.set({ [STORAGE_KEYS.ACTIVE_PUSHES]: pushes }); + } usage = await chrome.storage.local.getBytesInUse(null); if (usage <= target) return; diff --git a/src/ui/app/AppRoot.tsx b/src/ui/app/AppRoot.tsx index cd7afe6..dab9f2e 100644 --- a/src/ui/app/AppRoot.tsx +++ b/src/ui/app/AppRoot.tsx @@ -72,6 +72,14 @@ const BulkObjectOpsScreen = lazy(() => import(/* webpackChunkName: "adv-bulk-ops const RelationshipExplorerScreen = lazy(() => import(/* webpackChunkName: "adv-relationships" */ '../screens/RelationshipExplorerScreen').then(m => ({ default: m.RelationshipExplorerScreen }))); const HelpScreen = lazy(() => import(/* webpackChunkName: "help" */ '../screens/HelpScreen').then(m => ({ default: m.HelpScreen }))); +type LoadedDataset = { + sourceRecords: Record[]; + filename: string; + format: 'csv' | 'json' | 'excel' | 'xml'; + headers: string[]; + bytes?: number; +}; + export function AppRoot(): VNode { const sf = useMemo(() => new SfApi('app'), []); const [route, setRouteState] = useState(() => window.location.hash.replace(/^#\/?/, '') || 'home'); @@ -108,14 +116,16 @@ export function AppRoot(): VNode { }; const [soql, setSoql] = useState('SELECT Id, Name FROM Account LIMIT 10'); - const [dataset, setDataset] = useState<{ - sourceRecords: Record[]; - filename: string; - format: 'csv' | 'json' | 'excel' | 'xml'; - headers: string[]; - bytes?: number; - } | null>(null); + const [dataset, setDataset] = useState(null); const [cleaned, setCleaned] = useState<{ records: Record[]; headers: string[] } | null>(null); + // Cleaned rows belong to the dataset they were cleaned from. Every path that + // swaps in a different dataset (upload, retry, rollback, snapshot) must drop + // them, or DataPushScreen would push the old cleaned rows under the new file. + // The Cleanser hand-off is unaffected: it sets `cleaned` after loading. + const replaceDataset = useCallback((next: LoadedDataset | null): void => { + setDataset(next); + setCleaned(null); + }, []); const [theme, setTheme] = useState('light'); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); @@ -396,7 +406,7 @@ export function AppRoot(): VNode { dataset={dataset} cleanedRecords={cleaned?.records ?? null} cleanedHeaders={cleaned?.headers ?? null} - onDataset={setDataset} + onDataset={replaceDataset} onRequestCleanser={() => setRoute('advanced/cleanse')} savedJobDraft={importJobDraft} onSavedJobDraftConsumed={() => setImportJobDraft(undefined)} @@ -432,7 +442,7 @@ export function AppRoot(): VNode { tabId={selectedTabId ?? undefined} onOpenSchedules={() => setRoute('schedules')} onCreateImport={(records, headers, filename) => { - setDataset({ sourceRecords: records, headers, filename, format: 'json' }); + replaceDataset({ sourceRecords: records, headers, filename, format: 'json' }); setRoute('import'); }} /> @@ -456,20 +466,21 @@ export function AppRoot(): VNode { sf={sf} tabId={selectedTabId!} dataset={dataset} - onDataset={(next) => { setDataset(next); setCleaned(null); }} + onDataset={replaceDataset} onCleaned={(result) => setCleaned(result)} onGoToPush={() => setRoute('import')} - onClearDataset={() => { setDataset(null); setCleaned(null); }} + onClearDataset={() => replaceDataset(null)} /> ); if (route === 'advanced/push') return ( setRoute('advanced/cleanse')} /> ); @@ -570,8 +581,7 @@ export function AppRoot(): VNode { { Name: 'WaveLink Example Alpha', Type: 'Prospect' }, { Name: 'WaveLink Example Beta', Type: 'Customer - Direct' }, ]; - setDataset({ sourceRecords: records, headers: ['Name', 'Type'], filename: 'wavelink-safe-example.json', format: 'json' }); - setCleaned(null); + replaceDataset({ sourceRecords: records, headers: ['Name', 'Type'], filename: 'wavelink-safe-example.json', format: 'json' }); } }} /> diff --git a/src/ui/components/ExportModal.tsx b/src/ui/components/ExportModal.tsx index c11e1b0..9ee4861 100644 --- a/src/ui/components/ExportModal.tsx +++ b/src/ui/components/ExportModal.tsx @@ -59,7 +59,7 @@ export function ExportModal(props: ExportModalProps): VNode | null { // Validate Excel sheet name: max 31 chars, no : \ / ? * [ ] if (format === 'excel') { - const invalidChars = /[:\\/?*\[\]]/; + const invalidChars = /[:\\/?*[\]]/; if (!sheetName.trim()) { setError('Sheet name cannot be empty.'); return; diff --git a/src/ui/components/FieldRecommendations.tsx b/src/ui/components/FieldRecommendations.tsx index 8505dbf..d584b28 100644 --- a/src/ui/components/FieldRecommendations.tsx +++ b/src/ui/components/FieldRecommendations.tsx @@ -27,7 +27,7 @@ export function FieldRecommendations(props: FieldRecommendationsProps): VNode { } return ( -
    +
      {recommendations.map((rec, i) => (
    • {rec}
    • ))} diff --git a/src/ui/components/RelationshipConfig.tsx b/src/ui/components/RelationshipConfig.tsx index 0774bb6..e90dd53 100644 --- a/src/ui/components/RelationshipConfig.tsx +++ b/src/ui/components/RelationshipConfig.tsx @@ -1,4 +1,4 @@ -/** +/** * Configuration component for lookup relationship fields. * * Provides a textarea for entering lookup record IDs (one per line) diff --git a/src/ui/components/RelationshipTree.tsx b/src/ui/components/RelationshipTree.tsx index c808a13..59a0810 100644 --- a/src/ui/components/RelationshipTree.tsx +++ b/src/ui/components/RelationshipTree.tsx @@ -1,4 +1,4 @@ -/** +/** * Tree of objects with checkboxes and edge labels for the clone wizard. * * What this file does: diff --git a/src/ui/components/SchemaDiffView.tsx b/src/ui/components/SchemaDiffView.tsx index 766a8c2..a496dc2 100644 --- a/src/ui/components/SchemaDiffView.tsx +++ b/src/ui/components/SchemaDiffView.tsx @@ -1,4 +1,4 @@ -/** +/** * Scrollable list view of schema diffs with filter toolbar and summary stats. * * Renders a toolbar with all/added/removed/changed toggle buttons, diff --git a/src/ui/components/ShortcutEditor.tsx b/src/ui/components/ShortcutEditor.tsx index 32d8b4f..d9dc426 100644 --- a/src/ui/components/ShortcutEditor.tsx +++ b/src/ui/components/ShortcutEditor.tsx @@ -1,4 +1,4 @@ -/** +/** * Keyboard shortcut editor component. * * Displays a table of all registered shortcuts with their default and current diff --git a/src/ui/components/Skeleton.tsx b/src/ui/components/Skeleton.tsx index 6ed19ee..55cda64 100644 --- a/src/ui/components/Skeleton.tsx +++ b/src/ui/components/Skeleton.tsx @@ -1,4 +1,4 @@ -/** +/** * Reusable skeleton placeholder component for loading states. * * Replaces plain "Loading..." text with animated shimmer placeholders diff --git a/src/ui/components/StepConfigPanel.tsx b/src/ui/components/StepConfigPanel.tsx index 9bb5b39..26dc6e4 100644 --- a/src/ui/components/StepConfigPanel.tsx +++ b/src/ui/components/StepConfigPanel.tsx @@ -1,4 +1,4 @@ -/** +/** * Configuration form panel for a single pipeline step. * * What this file does: diff --git a/src/ui/components/TemplateCard.tsx b/src/ui/components/TemplateCard.tsx index cd2bc99..61ab54c 100644 --- a/src/ui/components/TemplateCard.tsx +++ b/src/ui/components/TemplateCard.tsx @@ -1,4 +1,4 @@ -/** +/** * Card component for displaying a saved data template. * * Renders the template name, object badge, category, usage count, diff --git a/src/ui/components/TemplateEditor.tsx b/src/ui/components/TemplateEditor.tsx index 3548769..bf9598d 100644 --- a/src/ui/components/TemplateEditor.tsx +++ b/src/ui/components/TemplateEditor.tsx @@ -1,4 +1,4 @@ -/** +/** * Form component for creating or editing a DataTemplate. * * Provides text inputs for name, description, objectName, and category, diff --git a/src/ui/components/UndoHistoryPanel.tsx b/src/ui/components/UndoHistoryPanel.tsx index e9c44ee..1c5b271 100644 --- a/src/ui/components/UndoHistoryPanel.tsx +++ b/src/ui/components/UndoHistoryPanel.tsx @@ -1,4 +1,4 @@ -/** +/** * Fixed bottom-right overlay panel for viewing and undoing push transactions. * * What this file does: diff --git a/src/ui/components/query-builder/SoqlPreview.tsx b/src/ui/components/query-builder/SoqlPreview.tsx index 49a8dae..daa6371 100644 --- a/src/ui/components/query-builder/SoqlPreview.tsx +++ b/src/ui/components/query-builder/SoqlPreview.tsx @@ -24,7 +24,7 @@ export function SoqlPreview(props: { Apply to Editor -
      {soql}
      +
      {soql}
      ); } diff --git a/src/ui/components/query-builder/SubqueryBuilder.tsx b/src/ui/components/query-builder/SubqueryBuilder.tsx index 2f80d4c..2f13583 100644 --- a/src/ui/components/query-builder/SubqueryBuilder.tsx +++ b/src/ui/components/query-builder/SubqueryBuilder.tsx @@ -1,4 +1,4 @@ -/** +/** * Child relationship subquery builder. * * Allows adding subqueries like (SELECT Id, Name FROM Contacts) to the diff --git a/src/ui/components/query-builder/WhereBuilder.tsx b/src/ui/components/query-builder/WhereBuilder.tsx index f451ada..496edd4 100644 --- a/src/ui/components/query-builder/WhereBuilder.tsx +++ b/src/ui/components/query-builder/WhereBuilder.tsx @@ -1,4 +1,4 @@ -import type { VNode } from 'preact'; +import type { VNode } from 'preact'; import { h } from 'preact'; import { useMemo } from 'preact/hooks'; import type { SObjectField } from '../../../core/types/salesforce'; diff --git a/src/ui/screens/CloneWizardScreen.tsx b/src/ui/screens/CloneWizardScreen.tsx index 1fa2d6b..840f15e 100644 --- a/src/ui/screens/CloneWizardScreen.tsx +++ b/src/ui/screens/CloneWizardScreen.tsx @@ -19,7 +19,9 @@ import { topologicalSort, detectCircularReferences, remapIds, + pairClonedIds, } from '../utils/crossObjectClone'; +import { awaitPushResult } from '../utils/migrationPush'; import { RelationshipTree } from '../components/RelationshipTree'; import { SearchableSelect } from '../components/SearchableSelect'; import { Toast } from '../components/Toast'; @@ -55,28 +57,6 @@ function stripForInsert(record: Record, describe: SObjectDescri return out; } -/** Poll for a completed push result. Resolves with the inserted IDs in order, or null on timeout. */ -async function awaitPushResult( - sf: SfApi, - pushId: string, - timeoutMs = 5 * 60_000, - intervalMs = 1_000, -): Promise<{ ids: string[]; failedIndices: Set } | null> { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const res = await sf.getDataPushResult(pushId); - if (res && Array.isArray(res.ids) && res.ids.length > 0) { - const failedIndices = new Set(); - if (res.failedRecords) { - for (const f of res.failedRecords) failedIndices.add(f.index); - } - return { ids: res.ids, failedIndices }; - } - await new Promise((r) => setTimeout(r, intervalMs)); - } - return null; -} - /** Wizard step labels. O(1). */ const STEPS = ['Root Object', 'Configure', 'Cycles', 'Cross-Org', 'Execute'] as const; @@ -305,18 +285,16 @@ export function CloneWizardScreen(props: CloneWizardScreenProps): VNode { continue; } - let mapped = 0; - let successIdx = 0; - for (let i = 0; i < sourceRecords.length; i++) { - if (pushOutcome.failedIndices.has(i)) continue; - const oldId = sourceRecords[i].Id as string | undefined; - const newId = pushOutcome.ids[successIdx++]; - if (oldId && newId) { - idMap.set(oldId, newId); - mapped++; - } + // Pair by per-row result index, never positionally (#49). + const pairing = pairClonedIds(sourceRecords, pushOutcome); + for (const { sourceId, targetId } of pairing.pairs) idMap.set(sourceId, targetId); + const failedNote = pairing.failed > 0 ? `; ${pairing.failed} failed` : ''; + log.push(` Inserted ${pushOutcome.ids.length} ${objectName} records${failedNote}; remapped ${pairing.pairs.length} IDs.`); + if (pairing.unmapped > 0) { + log.push( + ` Warning: ${pairing.unmapped} inserted ${objectName} ${pairing.unmapped === 1 ? 'ID' : 'IDs'} could not be matched to a source row and ${pairing.unmapped === 1 ? 'was' : 'were'} not remapped; related child records will not be re-parented to ${pairing.unmapped === 1 ? 'it' : 'them'}.`, + ); } - log.push(` Inserted ${pushOutcome.ids.length} ${objectName} records; remapped ${mapped} IDs.`); } catch (e) { log.push(` Insert failed: ${e instanceof Error ? e.message : 'Unknown error'}`); } diff --git a/src/ui/screens/DataComparisonScreen.tsx b/src/ui/screens/DataComparisonScreen.tsx index 94fc967..1a619e6 100644 --- a/src/ui/screens/DataComparisonScreen.tsx +++ b/src/ui/screens/DataComparisonScreen.tsx @@ -11,8 +11,8 @@ import { OrgPicker } from '../components/OrgPicker'; import { DataDiffView } from '../components/DataDiffView'; import { SearchableSelect } from '../components/SearchableSelect'; import { Toast } from '../components/Toast'; -import { diffRecords, diffToCsv } from '../utils/dataDiff'; -import type { DataDiffResult } from '../utils/dataDiff'; +import { buildCompareSyncPlan, diffRecords, diffToCsv } from '../utils/dataDiff'; +import type { CompareSyncPlan, DataDiffResult } from '../utils/dataDiff'; import { downloadTextFile } from '../utils/download'; import { TypedConfirmModal } from '../components/TypedConfirmModal'; @@ -28,6 +28,15 @@ interface FieldOption { externalId: boolean; } +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}`; +} + +function describeSyncCounts(plan: CompareSyncPlan): string { + if (plan.upserts > 0) return `${plural(plan.upserts, 'upsert')} by External ID`; + return `${plural(plan.inserts, 'insert')}, ${plural(plan.updates, 'update')}`; +} + const SYSTEM_FIELDS = new Set(['Id', 'CreatedDate', 'CreatedById', 'LastModifiedDate', 'LastModifiedById', 'SystemModstamp', 'IsDeleted']); export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }): VNode { @@ -114,7 +123,10 @@ export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }) setDiff(null); setSelectedKeys(new Set()); try { - const fields = [matchField, ...Array.from(selectedFields).filter(f => f !== matchField)]; + // Always select Id so Changed records can be updated by target Id; it is + // never compared because Ids differ between orgs. + const fields = [matchField, ...Array.from(selectedFields).filter(f => f !== matchField && f !== 'Id')]; + if (matchField !== 'Id') fields.push('Id'); const selectClause = fields.join(', '); let soql = `SELECT ${selectClause} FROM ${objectName}`; if (whereClause.trim()) soql += ` WHERE ${whereClause.trim()}`; @@ -132,7 +144,7 @@ export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }) return clean; }); - const compareFields = Array.from(selectedFields).filter(f => f !== matchField); + const compareFields = Array.from(selectedFields).filter(f => f !== matchField && f !== 'Id'); const result = diffRecords( stripAttributes(srcResult.records), stripAttributes(tgtResult.records), @@ -154,29 +166,18 @@ export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }) if (!diff || !targetOrgId || selectedKeys.size === 0) return; setSyncing(true); try { - const recordsToSync: Record[] = []; - for (const d of [...diff.added, ...diff.changed]) { - if (selectedKeys.has(d.keyValue) && d.sourceRecord) { - const rec = { ...d.sourceRecord }; - delete rec.attributes; - delete rec.Id; - for (const sf of SYSTEM_FIELDS) delete rec[sf]; - recordsToSync.push(rec); - } + const plan = buildSyncPlan(); + for (const job of plan.jobs) { + await sf.startDataPush({ + orgId: targetOrgId, + objectName, + operation: job.operation, + records: job.records, + externalIdField: job.externalIdField, + }); } - - const matchFieldMeta = commonFields.find(f => f.name === matchField); - const isExternalId = matchFieldMeta?.externalId ?? false; - const operation = isExternalId ? 'upsert' : 'insert'; - - await sf.startDataPush({ - orgId: targetOrgId, - objectName, - operation: operation as 'insert' | 'upsert', - records: recordsToSync, - externalIdField: isExternalId ? matchField : undefined, - }); - setToast({ title: 'Sync Started', body: `${recordsToSync.length} records being pushed to target org` }); + const skipped = plan.skippedKeys.length ? `; ${plan.skippedKeys.length} skipped (no target Id)` : ''; + setToast({ title: 'Sync Started', body: `${describeSyncCounts(plan)} being pushed to target org${skipped}` }); setReviewOpen(false); } catch (e) { setToast({ title: 'Sync Failed', body: e instanceof Error ? e.message : 'Unknown error' }); @@ -184,12 +185,22 @@ export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }) setSyncing(false); } + function buildSyncPlan(): CompareSyncPlan { + const matchFieldMeta = commonFields.find(f => f.name === matchField); + const isExternalId = matchFieldMeta?.externalId ?? false; + return buildCompareSyncPlan(diff!, selectedKeys, { + externalIdField: isExternalId ? matchField : undefined, + omitFields: SYSTEM_FIELDS, + }); + } + function exportDiff(): void { if (!diff) return; const csv = diffToCsv(diff); downloadTextFile(`compare-${objectName}-${Date.now()}.csv`, csv, 'text/csv'); } + const reviewPlan = reviewOpen && diff ? buildSyncPlan() : null; const canCompare = sourceOrgId && targetOrgId && objectName && matchField && selectedFields.size > 0; return ( @@ -352,8 +363,11 @@ export function DataComparisonScreen(props: { sf: SfApi; hideHeader?: boolean }) onConfirm={syncToTarget} >
      -

      Dry-run comparison complete. {selectedKeys.size} selected {objectName} records will be written to target org {targetOrgId}.

      -

      This controlled workflow copies one object at a time, removes Salesforce system fields, and uses upsert only when the match field is an External ID. It does not promise dependency migration or automatic rollback across objects.

      +

      Dry-run comparison complete. {selectedKeys.size} selected {objectName} records will be written to target org {targetOrgId}: {reviewPlan ? describeSyncCounts(reviewPlan) : ''}.

      + {reviewPlan && reviewPlan.skippedKeys.length ? ( +

      {reviewPlan.skippedKeys.length} changed records will be skipped because the target record Id was not returned.

      + ) : null} +

      This controlled workflow copies one object at a time and removes Salesforce system fields. It upserts when the match field is an External ID; otherwise it inserts Added records and updates Changed records by their target Id. It does not promise dependency migration or automatic rollback across objects.

      diff --git a/src/ui/screens/DataPushScreen.tsx b/src/ui/screens/DataPushScreen.tsx index eddec99..d8cd39d 100644 --- a/src/ui/screens/DataPushScreen.tsx +++ b/src/ui/screens/DataPushScreen.tsx @@ -18,7 +18,7 @@ import type { VNode } from 'preact'; import { h } from 'preact'; -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; import type { SfApi } from '../api/sf'; import { Toast } from '../components/Toast'; import { ConfirmModal } from '../components/ConfirmModal'; @@ -32,7 +32,7 @@ import { computePushProgress } from '../utils/pushMetrics'; import { DryRunPanel } from '../components/DryRunPanel'; import { simulatePush } from '../utils/pushDryRun'; import type { DryRunReport } from '../utils/pushDryRun'; -import { buildPushOutcomeDatasets, buildRetryDataset } from '../utils/pushRetry'; +import { buildPushOutcomeDatasets, buildRetryDataset, countMappingDroppedRows } from '../utils/pushRetry'; import { parseAnyFile } from '../utils/fileParse'; import { DataMapper } from '../../data/mappers'; import type { MappingMatchKind } from '../../data/mappers'; @@ -157,6 +157,11 @@ export function DataPushScreen(props: { const [availableObjects, setAvailableObjects] = useState>([]); const [describeFields, setDescribeFields] = useState(null); + // Describe fields of objects referenced by relationship lookups, so validation + // can confirm each lookup's match field is an External ID / idLookup field. + // Cached per tab so switching orgs never validates against another org's schema. + const [relatedDescribe, setRelatedDescribe] = useState<{ tabId: number | undefined; fields: Record }>({ tabId: undefined, fields: {} }); + const relatedFields = relatedDescribe.tabId === tabId ? relatedDescribe.fields : {}; const dataset = props.dataset; const datasetBytes = (dataset as unknown as { bytes?: number } | null)?.bytes ?? 0; @@ -171,6 +176,8 @@ export function DataPushScreen(props: { const [suggestions, setSuggestions] = useState>({}); const [mappingErrors, setMappingErrors] = useState | null>(null); const [mappedRecords, setMappedRecords] = useState[] | null>(null); + // mapped position -> source position; null when mappedRecords are the source rows 1:1 (#46). + const [mappedSourceIndexes, setMappedSourceIndexes] = useState(null); const [validationErrors, setValidationErrors] = useState | null>(null); const [dryRun, setDryRun] = useState(null); @@ -180,9 +187,18 @@ export function DataPushScreen(props: { const [pushErrors, setPushErrors] = useState | null>(null); const [lastPushConfig, setLastPushConfig] = useState<{ sourceRecords: Record[]; + /** Pushed position -> source position; push results must be translated through it (#46). */ + sourceIndexes?: number[]; + /** Mapping errors (source-indexed) for rows excluded before the push; reported in the error file. */ + mappingErrors?: Array<{ recordIndex: number; message: string }>; mappings: FieldMapping[]; } | null>(null); const busRef = useRef(null); + // Set just before this screen loads a dataset it built itself (retry rows, + // rollback IDs) alongside state restored in the same update. The + // dataset-change effects honour it instead of auto-mapping and resetting, + // so restored manual mappings and prepared records survive. + const restoredStateRef = useRef<{ filename: string; mappedRecords: Record[] | null } | null>(null); useEffect(() => { if (!savedJobPreset || savedJobPreset.definition.kind !== 'import') return; @@ -277,6 +293,35 @@ export function DataPushScreen(props: { .catch(e => setToast({ title: 'Describe Failed', body: e instanceof Error ? e.message : 'Unknown error' })); }, [sf, tabId, objectName]); + const lookupTargets = useMemo(() => { + if (!describeFields) return []; + const targets = new Set(); + for (const mapping of mappings) { + if (!mapping.lookup || mapping.lookup.mode === 'id') continue; + const referenceTo = describeFields.find(f => f.name === mapping.targetField)?.referenceTo ?? []; + if (referenceTo.length === 1) targets.add(referenceTo[0]); + } + return [...targets].sort(); + }, [describeFields, mappings]); + + useEffect(() => { + const missing = lookupTargets.filter(name => !relatedFields[name]); + if (missing.length === 0) return; + let cancelled = false; + Promise.all(missing.map(name => sf.describeSObject(name, tabId).then(d => [name, d.fields] as const))) + .then(entries => { + if (cancelled) return; + setRelatedDescribe(prev => ({ + tabId, + fields: { ...(prev.tabId === tabId ? prev.fields : {}), ...Object.fromEntries(entries) }, + })); + }) + .catch(() => { + // Validation still checks lookup shape; Salesforce rejects unusable match fields. + }); + return () => { cancelled = true; }; + }, [sf, tabId, lookupTargets.join('|'), relatedDescribe]); + useEffect(() => { if (!dataset) { setMappings([]); @@ -285,6 +330,10 @@ export function DataPushScreen(props: { setValidationErrors(null); return; } + if (restoredStateRef.current) { + restoredStateRef.current = null; + return; + } if (savedJobPreset?.definition.mappings?.length) { const bySource = new Map(savedJobPreset.definition.mappings.map(mapping => [mapping.sourceField, mapping])); setMappings(sourceHeaders.map(header => bySource.get(header) ?? { @@ -337,6 +386,30 @@ export function DataPushScreen(props: { })); }, [dataset?.filename, operation, sourceHeaders.join('|')]); + // Anything derived from the previous rows is stale once the dataset (or its + // cleaned rows) changes. Without this, Review stays reachable and Confirm + // would push the previous dataset's mapped records. A layout effect so the + // stale state is never interactive, even for a frame. + useLayoutEffect(() => { + let restored = restoredStateRef.current; + if (restored && restored.filename !== dataset?.filename) { + restoredStateRef.current = null; + restored = null; + } + setMappingErrors(null); + setDryRun(null); + if (restored?.mappedRecords) { + setMappedRecords(restored.mappedRecords); + setValidationErrors([]); + return; + } + setMappedRecords(null); + setValidationErrors(null); + if (!dataset) return; + setFurthestStage(current => Math.min(current, 1)); + setStage(current => (IMPORT_STAGES.findIndex(item => item.key === current) > 1 ? 'configure' : current)); + }, [dataset, props.cleanedRecords]); + const targetableFields = useMemo(() => { if (!describeFields) return []; return describeFields.filter(f => { @@ -391,6 +464,7 @@ export function DataPushScreen(props: { const usable = mappings.filter(m => m.targetField && m.targetField.trim().length > 0); const res = mapper.mapRecords(sourceRecords, usable); setMappedRecords(res.mappedRecords); + setMappedSourceIndexes(res.sourceIndexes); setMappingErrors(res.errors); setValidationErrors(null); setDryRun(null); @@ -401,6 +475,7 @@ export function DataPushScreen(props: { if (!mappedRecords || !describeFields) return; const report = simulatePush(mappedRecords, describeFields, operation, { externalIdField: operation === 'upsert' ? externalIdField : null, + relatedFields, }); setDryRun(report); moveToStage('review'); @@ -413,7 +488,7 @@ export function DataPushScreen(props: { function validate(): void { if (!mappedRecords || !describeFields) return; const validator = new DataValidator(); - const res = validator.validateRecords(mappedRecords, describeFields, operation); + const res = validator.validateRecords(mappedRecords, describeFields, operation, { relatedFields }); if (res.valid) { setValidationErrors([]); setToast({ title: 'Validation Passed', body: 'No errors found.' }); @@ -522,9 +597,11 @@ export function DataPushScreen(props: { } const records = res.ids.map(id => ({ Id: id })); + const filename = `rollback-${push.pushId}.json`; + restoredStateRef.current = { filename, mappedRecords: records }; props.onDataset({ sourceRecords: records, - filename: `rollback-${push.pushId}.json`, + filename, format: 'json', headers: ['Id'], }); @@ -533,6 +610,7 @@ export function DataPushScreen(props: { setExternalIdField(''); setMappings(makeEmptyMappings(['Id'])); setMappedRecords(records); + setMappedSourceIndexes(null); setMappingErrors(null); setValidationErrors([]); setToast({ title: 'Prepared Delete Push', body: `${records.length} IDs` }); @@ -550,12 +628,15 @@ export function DataPushScreen(props: { } try { - const retryData = buildRetryDataset(lastPushConfig.sourceRecords, pushErrors); + const retryData = buildRetryDataset(lastPushConfig.sourceRecords, pushErrors, lastPushConfig.sourceIndexes); - // Load retry dataset + // Load retry dataset; the flag keeps the automap effect from replacing + // the restored mappings below. + const filename = `retry-${push?.pushId || 'failed'}.json`; + restoredStateRef.current = { filename, mappedRecords: null }; props.onDataset({ sourceRecords: retryData.records, - filename: `retry-${push?.pushId || 'failed'}.json`, + filename, format: 'json', headers: retryData.headers, }); @@ -564,7 +645,10 @@ export function DataPushScreen(props: { setMappings(lastPushConfig.mappings); setRetryModalOpen(false); - setToast({ title: 'Retry Dataset Loaded', body: `${retryData.records.length} failed records loaded. Review mappings and push again.` }); + const skipped = droppedAtMapping > 0 + ? ` ${droppedAtMapping} ${droppedAtMapping === 1 ? 'row' : 'rows'} excluded at mapping ${droppedAtMapping === 1 ? 'is' : 'are'} not included; fix them from the error file.` + : ''; + setToast({ title: 'Retry Dataset Loaded', body: `${retryData.records.length} failed records loaded. Review mappings and push again.${skipped}` }); } catch (e) { setToast({ title: 'Retry Failed', body: e instanceof Error ? e.message : 'Unknown error' }); } @@ -575,7 +659,13 @@ export function DataPushScreen(props: { setBusy(true); try { const stored = kind === 'success' ? await sf.getDataPushResult(push.pushId) : null; - const datasets = buildPushOutcomeDatasets(lastPushConfig.sourceRecords, pushErrors ?? [], stored?.ids ?? []); + const datasets = buildPushOutcomeDatasets( + lastPushConfig.sourceRecords, + pushErrors ?? [], + stored ?? undefined, + lastPushConfig.sourceIndexes, + lastPushConfig.mappingErrors, + ); const selected = datasets[kind]; await exportRecords(selected.records, selected.headers, { format: 'csv', @@ -589,6 +679,9 @@ export function DataPushScreen(props: { } } + const droppedAtMapping = lastPushConfig + ? countMappingDroppedRows(lastPushConfig.sourceRecords.length, lastPushConfig.sourceIndexes) + : 0; const hasDataset = !!dataset; const datasetTooLarge = hasDataset ? estimateTooLarge(datasetBytes, sourceRecords.length) : null; const datasetSizeWarning = hasDataset ? estimateSizeWarning(datasetBytes, sourceRecords.length) : null; @@ -1137,7 +1230,7 @@ export function DataPushScreen(props: { <> - {push.failed > 0 ? : null} + {push.failed > 0 || droppedAtMapping > 0 ? : null} {push.failed > 0 && pushErrors && pushErrors.length > 0 ? ( @@ -1209,6 +1302,8 @@ export function DataPushScreen(props: { // Save push config for retry setLastPushConfig({ sourceRecords, + sourceIndexes: mappedSourceIndexes ?? undefined, + mappingErrors: mappedSourceIndexes ? (mappingErrors ?? undefined) : undefined, mappings: [...mappings], }); setToast({ title: 'Push Started', body: `${res.strategy.toUpperCase()} - ${res.pushId}` }); @@ -1295,6 +1390,8 @@ export function DataPushScreen(props: { // Save push config for retry setLastPushConfig({ sourceRecords, + sourceIndexes: mappedSourceIndexes ?? undefined, + mappingErrors: mappedSourceIndexes ? (mappingErrors ?? undefined) : undefined, mappings: [...mappings], }); setToast({ title: 'Push Started', body: `${res.strategy.toUpperCase()} - ${res.pushId}` }); diff --git a/src/ui/screens/JobsActivityScreen.tsx b/src/ui/screens/JobsActivityScreen.tsx index 8bf997e..6fd078c 100644 --- a/src/ui/screens/JobsActivityScreen.tsx +++ b/src/ui/screens/JobsActivityScreen.tsx @@ -1,4 +1,4 @@ -import { h } from 'preact'; +import { h } from 'preact'; import type { VNode } from 'preact'; import { useEffect, useMemo, useState } from 'preact/hooks'; import type { SfApi } from '../api/sf'; diff --git a/src/ui/screens/MigrationWorkspaceScreen.tsx b/src/ui/screens/MigrationWorkspaceScreen.tsx index ca95c2d..c853fec 100644 --- a/src/ui/screens/MigrationWorkspaceScreen.tsx +++ b/src/ui/screens/MigrationWorkspaceScreen.tsx @@ -1,5 +1,5 @@ -/** - * Migration Workspace screen — configures and executes a single migration project. +/** + * Migration Workspace screen — configures and executes a single migration project. * * Phase 1, Features 1.1 + 1.2: Project configuration and multi-object orchestration. * @@ -17,22 +17,7 @@ import type { SfApi } from '../api/sf'; import type { MigrationProject, MigrationObject, MigrationObjectProgress, DependencyGraph } from '../../core/types/migration'; import type { SObjectDescribe } from '../../core/types/salesforce'; import { buildMigrationGraph } from '../utils/dependencyGraph'; - -/** Poll for a completed push result. Resolves with the inserted IDs in order, or null on timeout. */ -async function awaitPushResult( - sf: SfApi, - pushId: string, - timeoutMs = 5 * 60_000, - intervalMs = 1_000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const res = await sf.getDataPushResult(pushId); - if (res && Array.isArray(res.ids) && res.ids.length > 0) return res.ids; - await new Promise((r) => setTimeout(r, intervalMs)); - } - return null; -} +import { awaitPushResult, buildIdMapEntries } from '../utils/migrationPush'; interface Props { sf: SfApi; @@ -190,7 +175,7 @@ export function MigrationWorkspaceScreen({ sf, tabId, projectId, onBack }: Props progress[i] = { ...prog }; setObjectProgress([...progress]); - // 2. Prepare records — remap reference fields using accumulated ID map + // 2. Prepare records — remap reference fields using accumulated ID map const records = queryResult.records.map((r: Record) => { const mapped: Record = {}; for (const [key, val] of Object.entries(r)) { @@ -219,33 +204,32 @@ export function MigrationWorkspaceScreen({ sf, tabId, projectId, onBack }: Props // 4. Poll for push completion before reading inserted IDs const result = await awaitPushResult(sf, pushResult.pushId); - if (result) { - const failedIndices = new Set(); - if (result.failedRecords) { - for (const f of result.failedRecords) failedIndices.add(f.index); - } - - const entries: Array<{ sourceId: string; targetId: string; objectName: string; createdAt: number }> = []; - let successIdx = 0; - for (let i = 0; i < queryResult.records.length; i++) { - if (failedIndices.has(i)) continue; - const sourceId = (queryResult.records[i] as Record).Id as string; - const targetId = result.ids[successIdx++]; - if (sourceId && targetId) { - entries.push({ sourceId, targetId, objectName: obj.objectName, createdAt: Date.now() }); - } - } + if (!result) { + throw new Error(`Timed out waiting for the ${obj.objectName} push to finish`); + } - // Add to local map for subsequent objects - for (const entry of entries) { - idMap.set(entry.sourceId, entry.targetId); - } + // Pair each new ID with the source row that produced it via idRecordIndexes; IDs can + // arrive out of input order, so they are never zipped positionally (#47). + const entries = buildIdMapEntries( + queryResult.records as Array>, + result, + obj.objectName, + ); + + // Add to local map for subsequent objects + for (const entry of entries) { + idMap.set(entry.sourceId, entry.targetId); + } - // Persist to ID map storage + // Persist to ID map storage + if (entries.length > 0) { await sf.addIdMapEntries(idMapId, entries); + } - prog.processedRecords = entries.length; - prog.failedRecords = prog.totalRecords - entries.length; + prog.processedRecords = entries.length; + prog.failedRecords = prog.totalRecords - entries.length; + if (result.failedRecords?.length) { + prog.errors = result.failedRecords.map(f => ({ recordIndex: f.index, message: f.error })); } prog.status = 'done'; diff --git a/src/ui/screens/SavedJobsScreen.tsx b/src/ui/screens/SavedJobsScreen.tsx index 9223e88..e797d88 100644 --- a/src/ui/screens/SavedJobsScreen.tsx +++ b/src/ui/screens/SavedJobsScreen.tsx @@ -1,4 +1,4 @@ -import { h } from 'preact'; +import { h } from 'preact'; import type { VNode } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import type { ExportTemplate, ImportTemplate, SavedJob, ScheduledExport } from '../../core/types/storage'; diff --git a/src/ui/utils/crossObjectClone.ts b/src/ui/utils/crossObjectClone.ts index aff0f07..dc974f2 100644 --- a/src/ui/utils/crossObjectClone.ts +++ b/src/ui/utils/crossObjectClone.ts @@ -148,3 +148,59 @@ export function remapIds( return remapped; }); } + +/** Per-row outcome of a clone insert, as stored by the background push. */ +export interface ClonePushOutcome { + ids: string[]; + /** `idRecordIndexes[k]` is the inserted-record index that produced `ids[k]` (-1 = unknown). */ + idRecordIndexes?: number[]; + failedRecords?: Array<{ index: number }>; +} + +export interface CloneIdPairing { + /** Source-org ID -> target-org ID for rows whose result identifies them. */ + pairs: Array<{ sourceId: string; targetId: string }>; + /** Inserted IDs that could not be tied to a source row (unknown or conflicting index). */ + unmapped: number; + /** Source rows reported as failed. */ + failed: number; +} + +/** + * Pair inserted target IDs with the source rows that produced them (#49). + * + * Records are pushed 1:1 with `sourceRecords`, so `idRecordIndexes` indexes it directly. IDs are + * never zipped positionally: Bulk and multi-threaded REST results arrive out of input order and + * failed rows return no ID, so a zip re-parents children onto the wrong records. IDs whose index + * is unknown (-1, missing, out of range), shared with another ID, or reported as failed are left + * unmapped and counted rather than guessed. O(N). + */ +export function pairClonedIds( + sourceRecords: ReadonlyArray>, + outcome: ClonePushOutcome, +): CloneIdPairing { + const failedIndexes = new Set((outcome.failedRecords ?? []).map(f => f.index)); + const ids = Array.isArray(outcome.ids) ? outcome.ids : []; + const idsByIndex = new Map(); + let unmapped = 0; + ids.forEach((targetId, k) => { + const index = outcome.idRecordIndexes?.[k] ?? -1; + const known = Number.isInteger(index) && index >= 0 && index < sourceRecords.length; + if (!targetId || !known || failedIndexes.has(index)) { + unmapped++; + return; + } + idsByIndex.set(index, [...(idsByIndex.get(index) ?? []), targetId]); + }); + + const pairs: CloneIdPairing['pairs'] = []; + for (const [index, targetIds] of idsByIndex) { + const sourceId = sourceRecords[index]?.Id; + if (targetIds.length === 1 && typeof sourceId === 'string' && sourceId) { + pairs.push({ sourceId, targetId: targetIds[0] }); + } else { + unmapped += targetIds.length; + } + } + return { pairs, unmapped, failed: failedIndexes.size }; +} diff --git a/src/ui/utils/csv.ts b/src/ui/utils/csv.ts index 4d3f377..8031637 100644 --- a/src/ui/utils/csv.ts +++ b/src/ui/utils/csv.ts @@ -18,10 +18,11 @@ function neutralizeFormulaInjection(s: string): string { function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ''; -// Serialize objects/arrays to JSON to avoid "[object Object]" in output, - // then neutralize formula-injection payloads on the resulting string. - const raw = typeof value === 'object' ? JSON.stringify(value) : String(value); - const s = neutralizeFormulaInjection(raw); + // Only string cells are guarded, as in the Excel exporter: a genuine number such as -5 must stay + // numeric rather than become the text '-5. Objects/arrays serialize to JSON (avoids "[object Object]"). + const s = typeof value === 'string' + ? neutralizeFormulaInjection(value) + : typeof value === 'object' ? JSON.stringify(value) : String(value); if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } diff --git a/src/ui/utils/dataDiff.ts b/src/ui/utils/dataDiff.ts index 109c0b6..4cede90 100644 --- a/src/ui/utils/dataDiff.ts +++ b/src/ui/utils/dataDiff.ts @@ -131,3 +131,73 @@ export function diffToCsv(diff: DataDiffResult): string { } return rows.join('\n'); } + +export interface CompareSyncJob { + operation: 'insert' | 'update' | 'upsert'; + records: Record[]; + externalIdField?: string; +} + +export interface CompareSyncPlan { + jobs: CompareSyncJob[]; + inserts: number; + updates: number; + upserts: number; + /** Selected Changed keys whose target record has no Id, so cannot be updated. */ + skippedKeys: string[]; +} + +/** + * Turn selected diff rows into push jobs for the target org. + * - External ID match field: one upsert of Added + Changed records keyed on it. + * - Otherwise: Added records are inserted, and Changed records are updated by + * the target record's Id with only the differing fields, so existing target + * rows are corrected rather than duplicated. + */ +export function buildCompareSyncPlan( + diff: DataDiffResult, + selectedKeys: Set, + options: { externalIdField?: string; omitFields?: Iterable }, +): CompareSyncPlan { + const omit = new Set(options.omitFields ?? []); + omit.add('Id'); + omit.add('attributes'); + const clean = (rec: Record, only?: string[]): Record => { + const out: Record = {}; + for (const key of only ?? Object.keys(rec)) { + if (!omit.has(key) && key in rec) out[key] = rec[key]; + } + return out; + }; + + const added = diff.added.filter(d => selectedKeys.has(d.keyValue) && d.sourceRecord); + const changed = diff.changed.filter(d => selectedKeys.has(d.keyValue) && d.sourceRecord); + + if (options.externalIdField) { + const records = [...added, ...changed].map(d => clean(d.sourceRecord!)); + return { + jobs: records.length ? [{ operation: 'upsert', records, externalIdField: options.externalIdField }] : [], + inserts: 0, + updates: 0, + upserts: records.length, + skippedKeys: [], + }; + } + + const inserts = added.map(d => clean(d.sourceRecord!)); + const updates: Record[] = []; + const skippedKeys: string[] = []; + for (const d of changed) { + const targetId = d.targetRecord?.Id; + if (typeof targetId !== 'string' || !targetId) { + skippedKeys.push(d.keyValue); + continue; + } + updates.push({ Id: targetId, ...clean(d.sourceRecord!, d.changedFields) }); + } + + const jobs: CompareSyncJob[] = []; + if (inserts.length) jobs.push({ operation: 'insert', records: inserts }); + if (updates.length) jobs.push({ operation: 'update', records: updates }); + return { jobs, inserts: inserts.length, updates: updates.length, upserts: 0, skippedKeys }; +} diff --git a/src/ui/utils/migrationPush.ts b/src/ui/utils/migrationPush.ts new file mode 100644 index 0000000..aa6a9cc --- /dev/null +++ b/src/ui/utils/migrationPush.ts @@ -0,0 +1,64 @@ +/** + * Helpers for reading a completed data push back into a migration run. + * + * Kept out of the screen so they can be unit-tested without rendering the + * Migration Workspace: `awaitPushResult` depends only on the injected API and + * `buildIdMapEntries` is pure. + */ + +import type { DataPushResultGetResponse } from '../../core/types/messaging'; +import type { IdMapEntry } from '../../core/types/migration'; + +/** Minimal API surface needed to poll for a push result. */ +export interface PushResultSource { + getDataPushResult(pushId: string): Promise; +} + +/** + * Poll until the background has stored the result for `pushId`. + * + * Push results are written only once the push reaches a terminal state, so the + * first non-null response is final, including when `ids` is empty because + * every record failed. Resolves with null on timeout. + */ +export async function awaitPushResult( + sf: PushResultSource, + pushId: string, + timeoutMs = 5 * 60_000, + intervalMs = 1_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await sf.getDataPushResult(pushId); + if (res) return res; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return null; +} + +/** + * Pair each target ID with the source record that produced it. + * + * `idRecordIndexes[k]` is the index of the pushed record that produced + * `ids[k]` (-1 when unknown). IDs can arrive out of input order, so they are + * never zipped positionally; IDs without a known source row are skipped rather + * than guessed, since a wrong pairing would link child records to the wrong + * parents in the target org. + */ +export function buildIdMapEntries( + sourceRecords: ReadonlyArray>, + result: Pick, + objectName: string, + now: number = Date.now(), +): IdMapEntry[] { + const ids = Array.isArray(result.ids) ? result.ids : []; + const entries: IdMapEntry[] = []; + ids.forEach((targetId, k) => { + const recordIndex = result.idRecordIndexes?.[k] ?? -1; + const sourceId = recordIndex >= 0 ? sourceRecords[recordIndex]?.Id : undefined; + if (typeof sourceId === 'string' && sourceId && targetId) { + entries.push({ sourceId, targetId, objectName, createdAt: now }); + } + }); + return entries; +} diff --git a/src/ui/utils/pushDryRun.ts b/src/ui/utils/pushDryRun.ts index f177b86..fd73abb 100644 --- a/src/ui/utils/pushDryRun.ts +++ b/src/ui/utils/pushDryRun.ts @@ -47,6 +47,8 @@ export interface DryRunReport { export interface DryRunOptions { /** External Id field selected for upsert (if any). */ externalIdField?: string | null; + /** Describe fields of objects referenced by relationship lookups. */ + relatedFields?: Record; } /** Salesforce record Ids are 15 (case-sensitive) or 18 (safe) alphanumeric chars. */ @@ -80,7 +82,7 @@ export function simulatePush( const reasons = new Set(); // 1. Schema validation via the shared, tested engine (run per-record). - const res = validator.validateRecords([record], fields, operation); + const res = validator.validateRecords([record], fields, operation, { relatedFields: options.relatedFields }); for (const e of res.errors) reasons.add(stripIndexPrefix(e.message)); // 2. Structural checks the schema validator doesn't cover. diff --git a/src/ui/utils/pushRetry.ts b/src/ui/utils/pushRetry.ts index d242749..84963bb 100644 --- a/src/ui/utils/pushRetry.ts +++ b/src/ui/utils/pushRetry.ts @@ -16,34 +16,91 @@ export interface PushOutcomeDatasets { error: { records: Record[]; headers: string[] }; } -/** Build complete success/error downloads while retaining every source column. */ +/** + * Translate a pushed-record index into a source-record index (#46). + * + * Push results are indexed by position in the pushed (mapped) array, which omits rows dropped at + * mapping. `sourceIndexes` is DataMapper's mapped -> source map; omit it when rows were pushed 1:1. + * Returns -1 for indices that do not identify a pushed row. + */ +export function toSourceRecordIndex(pushedIndex: number, sourceIndexes?: number[]): number { + if (!sourceIndexes) return pushedIndex; + return pushedIndex >= 0 && pushedIndex < sourceIndexes.length ? sourceIndexes[pushedIndex] : -1; +} + +/** Error-file message for a source row that mapping excluded from the push. */ +function describeMappingDrop(messages: string[] | undefined): string { + const detail = messages && messages.length > 0 ? messages.join('; ') : 'row excluded during field mapping'; + return `Not pushed (mapping error): ${detail}`; +} + +/** + * Number of source rows excluded at mapping, i.e. never pushed (#46). 0 when rows were pushed 1:1. + */ +export function countMappingDroppedRows(sourceRecordCount: number, sourceIndexes?: number[]): number { + if (!sourceIndexes) return 0; + const pushed = new Set(sourceIndexes.filter(index => index >= 0 && index < sourceRecordCount)); + return sourceRecordCount - pushed.size; +} + +/** + * Build complete success/error downloads while retaining every source column. + * + * `errors` and `rowIds.idRecordIndexes` are indexed by pushed position. IDs are attached only via + * `idRecordIndexes`: results can arrive out of order and successes need not return an ID, so a + * positional zip would label the wrong rows (#47). + * + * Rows dropped at mapping (source rows absent from `sourceIndexes`) were never pushed; they go to + * the error file with their `mappingErrors` (indexed by source position) so every source row lands + * in exactly one file. + */ export function buildPushOutcomeDatasets( originalRecords: Record[], errors: Array<{ recordIndex: number; message: string }>, - successfulIds: string[] = [], + rowIds: { ids: string[]; idRecordIndexes?: number[] } = { ids: [] }, + sourceIndexes?: number[], + mappingErrors: ReadonlyArray<{ recordIndex: number; message: string }> = [], ): PushOutcomeDatasets { + const inSource = (index: number): boolean => index >= 0 && index < originalRecords.length; const messages = new Map(); for (const error of errors) { - if (error.recordIndex < 0 || error.recordIndex >= originalRecords.length) continue; - messages.set(error.recordIndex, [...(messages.get(error.recordIndex) ?? []), error.message]); + const sourceIndex = toSourceRecordIndex(error.recordIndex, sourceIndexes); + if (!inSource(sourceIndex)) continue; + messages.set(sourceIndex, [...(messages.get(sourceIndex) ?? []), error.message]); + } + const idsBySource = new Map(); + rowIds.idRecordIndexes?.forEach((pushedIndex, i) => { + const sourceIndex = toSourceRecordIndex(pushedIndex, sourceIndexes); + const id = rowIds.ids[i]; + if (id && inSource(sourceIndex)) idsBySource.set(sourceIndex, id); + }); + const pushed = new Set(sourceIndexes ?? originalRecords.map((_, i) => i)); + const droppedMessages = new Map(); + for (const error of mappingErrors) { + if (!inSource(error.recordIndex) || pushed.has(error.recordIndex)) continue; + droppedMessages.set(error.recordIndex, [...(droppedMessages.get(error.recordIndex) ?? []), error.message]); } - let successIndex = 0; const successRecords: Record[] = []; const errorRecords: Record[] = []; - originalRecords.forEach((record, recordIndex) => { + for (let recordIndex = 0; recordIndex < originalRecords.length; recordIndex++) { + const record = originalRecords[recordIndex]; + if (!pushed.has(recordIndex)) { + errorRecords.push({ ...record, WaveLinkError: describeMappingDrop(droppedMessages.get(recordIndex)), WaveLinkSourceRow: recordIndex + 2 }); + continue; + } const rowErrors = messages.get(recordIndex); if (rowErrors) { errorRecords.push({ ...record, WaveLinkError: rowErrors.join('; '), WaveLinkSourceRow: recordIndex + 2 }); } else { - const id = successfulIds[successIndex++]; + const id = idsBySource.get(recordIndex); successRecords.push(id ? { ...record, WaveLinkRecordId: id } : { ...record }); } - }); + } const sourceHeaders = Array.from(new Set(originalRecords.flatMap(record => Object.keys(record)))); return { success: { records: successRecords, - headers: [...sourceHeaders, ...(successfulIds.length ? ['WaveLinkRecordId'] : [])], + headers: [...sourceHeaders, ...(idsBySource.size ? ['WaveLinkRecordId'] : [])], }, error: { records: errorRecords, @@ -55,17 +112,24 @@ export function buildPushOutcomeDatasets( /** * Builds a retry dataset containing only the records that failed in the original push. * + * Rows dropped at mapping are deliberately excluded: they were never sent, and re-pushing them + * with the same mappings cannot succeed. They are reported in the error file for the user to fix. + * * @param originalRecords - Original dataset records - * @param errors - Array of errors with record indices and messages + * @param errors - Array of errors with pushed-record indices and messages + * @param sourceIndexes - DataMapper's mapped -> source index map (#46); omit when rows were pushed 1:1 * @returns Retry dataset with failed records and error mapping */ export function buildRetryDataset( originalRecords: Record[], - errors: Array<{ recordIndex: number; message: string }> + errors: Array<{ recordIndex: number; message: string }>, + sourceIndexes?: number[], ): RetryDataset { + const sourceErrors = errors.map(e => ({ ...e, recordIndex: toSourceRecordIndex(e.recordIndex, sourceIndexes) })); + // Get unique failed indices (in case there are duplicate error entries) const failedIndices = Array.from( - new Set(errors.map(e => e.recordIndex).filter(idx => idx >= 0 && idx < originalRecords.length)) + new Set(sourceErrors.map(e => e.recordIndex).filter(idx => idx >= 0 && idx < originalRecords.length)) ).sort((a, b) => a - b); // Extract failed records @@ -80,7 +144,7 @@ export function buildRetryDataset( // Create error map: new index -> error message const errorMap = new Map(); - errors.forEach(error => { + sourceErrors.forEach(error => { const newIndex = failedIndices.indexOf(error.recordIndex); if (newIndex >= 0) { // If multiple errors for same record, concatenate them diff --git a/src/ui/utils/soqlBuilder.ts b/src/ui/utils/soqlBuilder.ts index 1f137ff..8fe8e5d 100644 --- a/src/ui/utils/soqlBuilder.ts +++ b/src/ui/utils/soqlBuilder.ts @@ -1,7 +1,7 @@ -/** +/** * SOQL generation from structured query builder state. * - * Pure functions — no side effects, no DOM. + * Pure functions — no side effects, no DOM. */ import type { SalesforceFieldType } from '../../core/types/salesforce'; diff --git a/tests/mocks/chromeMock.ts b/tests/mocks/chromeMock.ts index 0e2b213..7b7b805 100644 --- a/tests/mocks/chromeMock.ts +++ b/tests/mocks/chromeMock.ts @@ -9,6 +9,14 @@ const storageMock: Record> = { session: {}, }; +/** + * chrome.storage serialises values, so every get() returns a fresh copy. Returning the stored + * reference would let callers mutate "storage" without calling set(), hiding read-modify-write bugs. + */ +function cloneStored(value: T): T { + return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T; +} + const cookiesMock: Array<{ name: string; value: string; domain: string }> = []; const chrome = { @@ -27,8 +35,18 @@ const chrome = { }, lastError: null as chrome.runtime.LastError | null, getURL: jest.fn((path: string) => `chrome-extension://mock-id/${path}`), + getManifest: jest.fn(() => ({ manifest_version: 3, permissions: ['storage', 'unlimitedStorage'] })), id: 'mock-extension-id', }, + commands: { + onCommand: { addListener: jest.fn() }, + }, + alarms: { + get: jest.fn(() => Promise.resolve(undefined)), + create: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve(true)), + onAlarm: { addListener: jest.fn() }, + }, identity: { launchWebAuthFlow: jest.fn( (_details: unknown, callback: (responseUrl?: string) => void) => { @@ -44,7 +62,7 @@ const chrome = { const keyArray = Array.isArray(keys) ? keys : [keys]; for (const key of keyArray) { if (key in storageMock.local) { - result[key] = storageMock.local[key]; + result[key] = cloneStored(storageMock.local[key]); } } callback?.(result); @@ -66,6 +84,7 @@ const chrome = { storageMock.local = {}; return Promise.resolve(); }), + getBytesInUse: jest.fn(() => Promise.resolve(JSON.stringify(storageMock.local).length)), }, session: { get: jest.fn((keys: string | string[], callback?: (result: Record) => void) => { @@ -73,7 +92,7 @@ const chrome = { const keyArray = Array.isArray(keys) ? keys : [keys]; for (const key of keyArray) { if (key in storageMock.session) { - result[key] = storageMock.session[key]; + result[key] = cloneStored(storageMock.session[key]); } } callback?.(result); diff --git a/tests/mocks/runtimeMessages.ts b/tests/mocks/runtimeMessages.ts new file mode 100644 index 0000000..0123b98 --- /dev/null +++ b/tests/mocks/runtimeMessages.ts @@ -0,0 +1,54 @@ +/** + * Dispatch a message through every chrome.runtime.onMessage listener registered + * on the chrome mock, the way Chrome does: each listener is offered the + * message, the first one that returns `true` keeps the channel open and its + * `sendResponse` value is the reply. Resolves NOT_HANDLED when no listener + * claims the message. + * + * The background module registers more than one listener (the MessageBus and a + * raw scheduler-control listener), so tests must not assume the most recently + * registered listener is the MessageBus. + */ + +import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; + +type Listener = ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void, +) => boolean | undefined | void; + +export function dispatchRuntimeMessage( + message: ExtensionMessage, + sender: chrome.runtime.MessageSender = {}, +): Promise { + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls + .map(call => call[0] as Listener); + + return new Promise((resolve) => { + let settled = false; + const sendResponse = (response: unknown) => { + if (settled) return; + settled = true; + resolve(response as MessageResponse); + }; + + let claimed = false; + for (const listener of listeners) { + if (listener(message, sender, sendResponse) === true) { + claimed = true; + break; + } + if (settled) return; + } + + if (!claimed && !settled) { + settled = true; + resolve({ + success: false, + error: { code: 'NOT_HANDLED', message: `No listener handled ${message.type}` }, + requestId: message.requestId, + }); + } + }); +} diff --git a/tests/unit/api-rawcall.test.ts b/tests/unit/api-rawcall.test.ts index 3ca4132..a303344 100644 --- a/tests/unit/api-rawcall.test.ts +++ b/tests/unit/api-rawcall.test.ts @@ -52,10 +52,19 @@ describe('SalesforceApiClient.rawCall', () => { expect(calls[0].url).toBe('https://example.my.salesforce.com/services/data'); }); - it('uses an absolute http(s) URL verbatim', async () => { + it('uses a same-origin absolute http(s) URL verbatim', async () => { const { calls } = mockFetch(jsonResp(200, true, {})); - await client().rawCall('GET', 'https://other.example.com/foo'); - expect(calls[0].url).toBe('https://other.example.com/foo'); + await client().rawCall('GET', 'https://example.my.salesforce.com/services/apexrest/foo'); + expect(calls[0].url).toBe('https://example.my.salesforce.com/services/apexrest/foo'); + }); + + it('refuses a cross-origin absolute URL without sending the bearer token (#65)', async () => { + const { calls } = mockFetch(jsonResp(200, true, {})); + await expect(client().rawCall('GET', 'https://other.example.com/foo')).rejects.toMatchObject({ + statusCode: 403, + sfErrorCode: 'URL_ORIGIN_MISMATCH', + }); + expect(calls).toHaveLength(0); }); it('returns status + ok + parsed JSON body without throwing on 4xx', async () => { diff --git a/tests/unit/background-auth-handlers.test.ts b/tests/unit/background-auth-handlers.test.ts index 4705541..be49f1d 100644 --- a/tests/unit/background-auth-handlers.test.ts +++ b/tests/unit/background-auth-handlers.test.ts @@ -12,6 +12,7 @@ import { StorageService } from '../../src/services/storage'; import { SalesforceAuth } from '../../src/services/salesforce/auth'; import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; +import { dispatchRuntimeMessage } from '../mocks/runtimeMessages'; import type { SalesforceOrg } from '../../src/core/types/salesforce'; // Import background module to register handlers on the MessageBus. @@ -43,23 +44,10 @@ function makeMessage(type: string, payload: T): ExtensionMessage { }; } -function getRegisteredHandler(type: string) { - // The MessageBus registers listeners via chrome.runtime.onMessage.addListener. - // We capture the listener from the mock and invoke it directly. - const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; - // The last registered listener is the background handler (singleton MessageBus). - const listener = listeners[listeners.length - 1]?.[0]; - if (!listener) throw new Error(`No listener registered for ${type}`); - - return async (message: ExtensionMessage): Promise => { - return new Promise((resolve) => { - const result = listener(message, {}, resolve); - // If the handler returns false or undefined synchronously, it was not handled. - if (result === false || result === undefined) { - resolve({ success: false, error: { code: 'NOT_HANDLED', message: 'No handler' }, requestId: message.requestId }); - } - }); - }; +function getRegisteredHandler(_type: string) { + // Route through every registered onMessage listener, as Chrome does; the + // background registers both the MessageBus and a scheduler-control listener. + return (message: ExtensionMessage): Promise => dispatchRuntimeMessage(message); } describe('Background Auth Handlers', () => { @@ -128,8 +116,10 @@ describe('Background Auth Handlers', () => { const response = await handler(message); expect(response.success).toBe(true); + // StorageService.getOrg returns null (not undefined) for a missing org. const storedOrg = await storage.getOrg(mockOrg.orgId); - expect(storedOrg).toBeUndefined(); + expect(storedOrg).toBeNull(); + expect(await storage.getActiveOrgId()).toBeNull(); }); it('succeeds even when org does not exist', async () => { diff --git a/tests/unit/background-services.test.ts b/tests/unit/background-services.test.ts index 63a0b31..85876ef 100644 --- a/tests/unit/background-services.test.ts +++ b/tests/unit/background-services.test.ts @@ -186,11 +186,8 @@ describe('SalesforceAuth', () => { describe('login', () => { it('throws AuthError when no Salesforce tab is open', async () => { - (chrome.tabs.query as jest.Mock).mockImplementation( - (_queryInfo: unknown, callback: (tabs: unknown[]) => void) => { - callback([]); - }, - ); + // SalesforceAuth uses the promise form of chrome.tabs.query (MV3). + (chrome.tabs.query as jest.Mock).mockResolvedValueOnce([]); await expect(auth.login()).rejects.toThrow(/Open an authenticated Salesforce tab/); }); diff --git a/tests/unit/background-storage-handlers.test.ts b/tests/unit/background-storage-handlers.test.ts index 3ea87eb..3310a7f 100644 --- a/tests/unit/background-storage-handlers.test.ts +++ b/tests/unit/background-storage-handlers.test.ts @@ -9,6 +9,7 @@ import { StorageService } from '../../src/services/storage'; import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; +import { dispatchRuntimeMessage } from '../mocks/runtimeMessages'; import type { MigrationProject } from '../../src/core/types/migration'; // Import background module to register handlers on the MessageBus. @@ -24,19 +25,10 @@ function makeMessage(type: string, payload: T): ExtensionMessage { }; } -function getRegisteredHandler(type: string) { - const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; - const listener = listeners[listeners.length - 1]?.[0]; - if (!listener) throw new Error(`No listener registered for ${type}`); - - return async (message: ExtensionMessage): Promise => { - return new Promise((resolve) => { - const result = listener(message, {}, resolve); - if (result === false || result === undefined) { - resolve({ success: false, error: { code: 'NOT_HANDLED', message: 'No handler' }, requestId: message.requestId }); - } - }); - }; +function getRegisteredHandler(_type: string) { + // Route through every registered onMessage listener, as Chrome does; the + // background registers both the MessageBus and a scheduler-control listener. + return (message: ExtensionMessage): Promise => dispatchRuntimeMessage(message); } describe('Background Storage & Migration Handlers', () => { diff --git a/tests/unit/bulk-csv-fidelity.test.ts b/tests/unit/bulk-csv-fidelity.test.ts new file mode 100644 index 0000000..348ac59 --- /dev/null +++ b/tests/unit/bulk-csv-fidelity.test.ts @@ -0,0 +1,68 @@ +/** + * Bulk API CSV must round-trip field values exactly. + * + * - Upload (#66 regression): the spreadsheet formula-injection guard from PR #118 + * was also applied to the Bulk ingest CSV, so values starting with - + = @ were + * uploaded with a leading apostrophe (stored in Salesforce, or rejected for + * number fields). The REST path never did this. + * - Query (#80 regression): PR #119 enabled Papa `dynamicTyping`, which coerced + * Text values ("00123" -> 123, long numeric strings lost precision, "true" -> true). + */ + +import { BulkApiService, bulkCsvCellValue } from '../../src/services/salesforce/bulk-api'; + +function service(): BulkApiService { + return new BulkApiService({ + instanceUrl: 'https://example.my.salesforce.com', + accessToken: 'token', + apiVersion: 'v65.0', + }); +} + +function csvResponse(body: string): Response { + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => body, + } as unknown as Response; +} + +describe('Bulk ingest CSV sends values verbatim', () => { + it.each([ + ['negative number', -5, '-5'], + ['negative decimal string', '-12.50', '-12.50'], + ['international phone', '+44 20 7946 0000', '+44 20 7946 0000'], + ['handle', '@wavelink', '@wavelink'], + ['leading equals', '=1+1', '=1+1'], + ])('%s', (_label, value, expected) => { + expect(bulkCsvCellValue({ Field__c: value }, 'Field__c')).toBe(expected); + }); + + it('does not prefix an apostrophe anywhere in the uploaded CSV', () => { + const csv = service().recordsToCsv([{ Amount: -5, Phone: '+1 555 0100' }]); + expect(csv).toBe('Amount,Phone\n-5,+1 555 0100'); + }); +}); + +describe('Bulk query results keep CSV values as strings', () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it('preserves leading zeros, long numeric strings, and boolean-looking text', async () => { + global.fetch = jest.fn().mockResolvedValue(csvResponse( + 'Id,PostalCode,External_Id__c,Flag_Text__c\n001xx0000000001AAA,02134,123456789012345678,true\n', + )); + + const page = await service().getQueryResults('750xx'); + + expect(page.records).toEqual([{ + Id: '001xx0000000001AAA', + PostalCode: '02134', + External_Id__c: '123456789012345678', + Flag_Text__c: 'true', + }]); + }); +}); diff --git a/tests/unit/bulk-ingest.test.ts b/tests/unit/bulk-ingest.test.ts new file mode 100644 index 0000000..ea7309d --- /dev/null +++ b/tests/unit/bulk-ingest.test.ts @@ -0,0 +1,134 @@ +import Papa from 'papaparse'; +import { BulkApiService } from '../../src/services/salesforce/bulk-api'; +import type { BulkJobResult } from '../../src/services/salesforce/bulk-api'; +import { buildBulkRowIdentity, fetchBulkRowResults, mapBulkResultsToInput } from '../../src/services/salesforce/bulk-results'; + +function service(): BulkApiService { + return new BulkApiService({ + instanceUrl: 'https://example.my.salesforce.com', + accessToken: 'test', + apiVersion: 'v65.0', + }); +} + +/** Simulate a Bulk result row: sf__ columns plus the uploaded columns echoed back as strings. */ +function echo(csv: string, inputRow: number, extra: Record): BulkJobResult { + const rows = Papa.parse>(csv, { header: true, skipEmptyLines: true }).data; + return { sf__Id: '', sf__Created: '', sf__Error: '', ...extra, ...rows[inputRow] }; +} + +describe('Bulk ingest CSV (#45)', () => { + test('header row is the union of keys across all records', () => { + // Blank-ignored cells are omitted by the mapper, so row 0 has no Phone key. + const csv = service().recordsToCsv([ + { Name: 'Acme' }, + { Name: 'Globex', Phone: '555-0100' }, + { Name: 'Initech', Website: 'initech.test' }, + ]); + expect(csv.split('\n')).toEqual([ + 'Name,Phone,Website', + 'Acme,,', + 'Globex,555-0100,', + 'Initech,,initech.test', + ]); + }); + + test('explicit null (blank means clear) is sent as #N/A; missing keys stay blank', () => { + const csv = service().recordsToCsv([ + { Id: '001A', Phone: null, Website: 'keep.test' }, + { Id: '001B', Website: undefined }, + ]); + expect(csv.split('\n')).toEqual([ + 'Id,Phone,Website', + '001A,#N/A,keep.test', + '001B,,', + ]); + }); + + test('quotes values containing a carriage return', () => { + const csv = service().recordsToCsv([{ Name: 'Acme', Description: 'line one\rline two' }]); + expect(csv).toBe('Name,Description\nAcme,"line one\rline two"'); + expect(Papa.parse(csv, { header: true }).data).toEqual([{ Name: 'Acme', Description: 'line one\rline two' }]); + }); +}); + +describe('Bulk per-row result mapping (#47)', () => { + const records = [ + { Name: 'Acme', Phone: '1' }, + { Name: 'Globex', Phone: null }, + { Name: 'Initech' }, + { Name: 'Umbrella', Phone: '4' }, + ]; + const csv = service().recordsToCsv(records); + + test('maps out-of-order successful and failed rows back to input indices', () => { + const identity = buildBulkRowIdentity(records); + const successful = [ + echo(csv, 3, { sf__Id: '001U', sf__Created: 'true' }), + echo(csv, 0, { sf__Id: '001A', sf__Created: 'true' }), + ]; + const failed = [ + echo(csv, 2, { sf__Error: 'REQUIRED_FIELD_MISSING:Required fields are missing: [Industry]' }), + echo(csv, 1, { sf__Error: 'INVALID_FIELD:Bad phone' }), + ]; + + expect(mapBulkResultsToInput(identity, successful, failed)).toEqual({ + ids: ['001U', '001A'], + idRecordIndexes: [3, 0], + errors: [ + { recordIndex: 2, message: 'REQUIRED_FIELD_MISSING:Required fields are missing: [Industry]' }, + { recordIndex: 1, message: 'INVALID_FIELD:Bad phone' }, + ], + }); + }); + + test('tolerates echoed whitespace, header case, and a blanked #N/A marker', () => { + const identity = buildBulkRowIdentity(records); + const failed: BulkJobResult[] = [{ sf__Id: '', sf__Created: '', sf__Error: 'Bad', NAME: ' Globex ', phone: '' }]; + expect(mapBulkResultsToInput(identity, [], failed).errors).toEqual([{ recordIndex: 1, message: 'Bad' }]); + }); + + test('claims identical duplicate rows once each, in input order', () => { + const dupes = [{ Name: 'Same' }, { Name: 'Other' }, { Name: 'Same' }]; + const dupeCsv = service().recordsToCsv(dupes); + const result = mapBulkResultsToInput( + buildBulkRowIdentity(dupes), + [echo(dupeCsv, 0, { sf__Id: '001X' }), echo(dupeCsv, 2, { sf__Id: '001Y' })], + [echo(dupeCsv, 0, { sf__Error: 'Surplus echo' })], + ); + expect(result.idRecordIndexes).toEqual([0, 2]); + expect(result.errors).toEqual([{ recordIndex: -1, message: 'Surplus echo' }]); + }); + + test('reports unidentifiable rows as -1 instead of guessing a position', () => { + const result = mapBulkResultsToInput( + undefined, + [echo(csv, 0, { sf__Id: '001A' })], + [echo(csv, 1, { sf__Error: 'Bad' })], + ); + expect(result).toEqual({ ids: ['001A'], idRecordIndexes: [-1], errors: [{ recordIndex: -1, message: 'Bad' }] }); + }); + + test('fetches both result files and keeps going when one cannot be read', async () => { + const identity = buildBulkRowIdentity(records); + const result = await fetchBulkRowResults({ + getSuccessfulResults: jest.fn().mockRejectedValue(new Error('403')), + getFailedResults: jest.fn().mockResolvedValue([echo(csv, 2, { sf__Error: 'Bad' })]), + }, '750xx', identity); + expect(result).toEqual({ ids: [], idRecordIndexes: [], errors: [{ recordIndex: 2, message: 'Bad' }] }); + }); + + test('result getters reject non-OK responses instead of parsing an error body as rows', async () => { + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => '[{"errorCode":"INVALID_SESSION_ID"}]', + } as unknown as Response); + try { + await expect(service().getFailedResults('750xx')).rejects.toThrow('Failed to retrieve bulk job results'); + } finally { + global.fetch = originalFetch; + } + }); +}); diff --git a/tests/unit/bulk-query-typing.test.ts b/tests/unit/bulk-query-typing.test.ts new file mode 100644 index 0000000..46cef8d --- /dev/null +++ b/tests/unit/bulk-query-typing.test.ts @@ -0,0 +1,165 @@ +/** + * Bulk query results must export the same JSON types as REST (#80). + * + * Bulk API 2.0 returns CSV, so every value is a string. Types come from the queried object's + * describe — never from guessing at the CSV text, which corrupted Text fields in PR #119. + */ + +import { + resolveBulkFieldTypes, + typeBulkQueryPage, + typeBulkQueryValue, +} from '../../src/services/salesforce/bulk-query-typing'; +import type { SObjectDescribe, SObjectField, SalesforceFieldType } from '../../src/core/types/salesforce'; + +function field(name: string, type: SalesforceFieldType, extra: Partial = {}): SObjectField { + return { + name, label: name, type, length: 0, required: false, createable: true, updateable: true, + nillable: true, defaultValue: null, externalId: false, unique: false, ...extra, + }; +} + +function sobject(name: string, fields: SObjectField[]): SObjectDescribe { + return { + name, label: name, labelPlural: name, keyPrefix: null, custom: false, + createable: true, updateable: true, deletable: true, fields, + }; +} + +const DESCRIBES: Record = { + Contact: sobject('Contact', [ + field('Id', 'id'), + field('MailingPostalCode', 'string'), + field('External_Id__c', 'string'), + field('Flag_Text__c', 'string'), + field('Big_Number_Text__c', 'textarea'), + field('Is_Active__c', 'boolean'), + field('Employees__c', 'int'), + field('Revenue__c', 'currency'), + field('Score__c', 'double'), + field('Discount__c', 'percent'), + field('Birthdate', 'date'), + field('LastModifiedDate', 'datetime'), + field('AccountId', 'reference', { referenceTo: ['Account'], relationshipName: 'Account' }), + field('WhatId', 'reference', { referenceTo: ['Account', 'Opportunity'], relationshipName: 'What' }), + ]), + Account: sobject('Account', [ + field('Name', 'string'), + field('AnnualRevenue', 'currency'), + field('IsPartner', 'boolean'), + field('OwnerId', 'reference', { referenceTo: ['User'], relationshipName: 'Owner' }), + ]), + User: sobject('User', [ + field('Name', 'string'), + field('IsActive', 'boolean'), + ]), +}; + +const describe_ = jest.fn(async (name: string) => DESCRIBES[name] ?? null); + +beforeEach(() => describe_.mockClear()); + +describe('Bulk query values typed from describe', () => { + it('keeps Text values that only look numeric or boolean as strings', async () => { + const [record] = await typeBulkQueryPage([{ + Id: '003xx0000000001AAA', + MailingPostalCode: '02134', + External_Id__c: '123456789012345678', + Flag_Text__c: 'true', + Big_Number_Text__c: '-5', + }], 'Contact', describe_); + + expect(record).toEqual({ + Id: '003xx0000000001AAA', + MailingPostalCode: '02134', + External_Id__c: '123456789012345678', + Flag_Text__c: 'true', + Big_Number_Text__c: '-5', + }); + }); + + it('types boolean and numeric fields like REST does', async () => { + const [record] = await typeBulkQueryPage([{ + Is_Active__c: 'true', + Employees__c: '42', + Revenue__c: '500000.0', + Score__c: '-12.75', + Discount__c: '15', + }], 'Contact', describe_); + + expect(record).toEqual({ + Is_Active__c: true, + Employees__c: 42, + Revenue__c: 500000, + Score__c: -12.75, + Discount__c: 15, + }); + }); + + it('keeps dates and datetimes as the strings REST returns', async () => { + const [record] = await typeBulkQueryPage([{ + Birthdate: '1990-04-01', + LastModifiedDate: '2026-10-04T12:00:00.000Z', + }], 'Contact', describe_); + + expect(record).toEqual({ Birthdate: '1990-04-01', LastModifiedDate: '2026-10-04T12:00:00.000Z' }); + }); + + it('maps empty cells to null, matching REST', async () => { + const [record] = await typeBulkQueryPage([{ + MailingPostalCode: '', Is_Active__c: '', Revenue__c: '', Unknown__c: '', + }], 'Contact', describe_); + + expect(record).toEqual({ MailingPostalCode: null, Is_Active__c: null, Revenue__c: null, Unknown__c: null }); + }); + + it('keeps numeric values as strings when a JS number would lose precision', () => { + expect(typeBulkQueryValue('123456789012345678', 'int')).toBe('123456789012345678'); + expect(typeBulkQueryValue('1234567890123456.78', 'currency')).toBe('1234567890123456.78'); + expect(typeBulkQueryValue('0.1234567890123456789', 'double')).toBe('0.1234567890123456789'); + expect(typeBulkQueryValue('123456789012345.0000', 'currency')).toBe(123456789012345); + expect(typeBulkQueryValue('9007199254740991', 'int')).toBe(9007199254740991); + }); + + it('leaves malformed values as strings instead of producing NaN or coercing', () => { + expect(typeBulkQueryValue('abc', 'double')).toBe('abc'); + expect(typeBulkQueryValue('1.5', 'int')).toBe('1.5'); + expect(typeBulkQueryValue('yes', 'boolean')).toBe('yes'); + expect(typeBulkQueryValue('.', 'double')).toBe('.'); + }); + + it('resolves relationship columns through referenceTo describes', async () => { + const [record] = await typeBulkQueryPage([{ + 'Account.Name': '00123', + 'Account.AnnualRevenue': '250000', + 'account.ispartner': 'false', + 'Account.Owner.IsActive': 'true', + 'Account.Owner.Name': 'true', + }], 'Contact', describe_); + + expect(record).toEqual({ + 'Account.Name': '00123', + 'Account.AnnualRevenue': 250000, + 'account.ispartner': false, + 'Account.Owner.IsActive': true, + 'Account.Owner.Name': 'true', + }); + // Each object is described once per page, however many columns reference it. + expect(describe_.mock.calls.map(([name]) => name).sort()).toEqual(['Account', 'Contact', 'User']); + }); + + it('leaves polymorphic and unresolvable relationship columns as strings', async () => { + const types = await resolveBulkFieldTypes('Contact', ['What.Name', 'Nope.Amount', 'Account.Missing__c'], describe_); + expect(types).toEqual({ 'What.Name': undefined, 'Nope.Amount': undefined, 'Account.Missing__c': undefined }); + }); + + it('falls back to strings when the queried object or describe is unavailable', async () => { + const failing = jest.fn(async () => { + throw new Error('describe failed'); + }); + expect(await typeBulkQueryPage([{ Employees__c: '42', Name: '' }], 'Contact', failing)) + .toEqual([{ Employees__c: '42', Name: null }]); + expect(await typeBulkQueryPage([{ Employees__c: '42' }], undefined, describe_)) + .toEqual([{ Employees__c: '42' }]); + }); +}); diff --git a/tests/unit/crossObjectClone.test.ts b/tests/unit/crossObjectClone.test.ts index 0d3585b..e2d7481 100644 --- a/tests/unit/crossObjectClone.test.ts +++ b/tests/unit/crossObjectClone.test.ts @@ -3,6 +3,7 @@ import { topologicalSort, detectCircularReferences, remapIds, + pairClonedIds, } from '../../src/ui/utils/crossObjectClone'; import type { CloneGraph } from '../../src/ui/utils/crossObjectClone'; @@ -282,3 +283,56 @@ describe('remapIds', () => { expect(result[0].Name).toBe('Test'); }); }); + +describe('pairClonedIds (#49)', () => { + const sources = [{ Id: 'S0' }, { Id: 'S1' }, { Id: 'S2' }, { Id: 'S3' }]; + + it('pairs IDs by result index when a row in the middle failed', () => { + const result = pairClonedIds(sources, { + ids: ['T0', 'T2', 'T3'], + idRecordIndexes: [0, 2, 3], + failedRecords: [{ index: 1 }], + }); + expect(result.pairs).toEqual([ + { sourceId: 'S0', targetId: 'T0' }, + { sourceId: 'S2', targetId: 'T2' }, + { sourceId: 'S3', targetId: 'T3' }, + ]); + expect(result).toEqual(expect.objectContaining({ unmapped: 0, failed: 1 })); + }); + + it('pairs by result index when a middle failure has no identifiable index (Bulk reports -1)', () => { + // Background drops errors with recordIndex -1 from failedRecords, so only idRecordIndexes says which row failed. + const result = pairClonedIds(sources, { ids: ['T0', 'T2', 'T3'], idRecordIndexes: [0, 2, 3] }); + expect(result.pairs).toEqual([ + { sourceId: 'S0', targetId: 'T0' }, + { sourceId: 'S2', targetId: 'T2' }, + { sourceId: 'S3', targetId: 'T3' }, + ]); + }); + + it('pairs IDs that arrive out of input order (Bulk / parallel REST batches)', () => { + const result = pairClonedIds(sources, { + ids: ['T3', 'T1', 'T0', 'T2'], + idRecordIndexes: [3, 1, 0, 2], + }); + expect(new Map(result.pairs.map(p => [p.sourceId, p.targetId]))).toEqual( + new Map([['S0', 'T0'], ['S1', 'T1'], ['S2', 'T2'], ['S3', 'T3']]), + ); + }); + + it('leaves IDs with unknown or conflicting indexes unmapped instead of guessing', () => { + const result = pairClonedIds(sources, { + ids: ['T0', 'TX', 'T2a', 'T2b', 'T9'], + idRecordIndexes: [0, -1, 2, 2, 9], + }); + expect(result.pairs).toEqual([{ sourceId: 'S0', targetId: 'T0' }]); + expect(result.unmapped).toBe(4); + }); + + it('maps nothing when the result has no idRecordIndexes', () => { + const result = pairClonedIds(sources, { ids: ['T0', 'T1'] }); + expect(result.pairs).toEqual([]); + expect(result.unmapped).toBe(2); + }); +}); diff --git a/tests/unit/csv-formula-guard.test.ts b/tests/unit/csv-formula-guard.test.ts new file mode 100644 index 0000000..3c3c3c5 --- /dev/null +++ b/tests/unit/csv-formula-guard.test.ts @@ -0,0 +1,40 @@ +/** + * CSV export formula-injection guard (#66) applies to string cells only. + * + * The guard stringified every value first, so genuine negative numbers exported as the text '-5. + * The Excel exporter already guards strings only; CSV now matches it. + */ + +import { recordsToCsv } from '../../src/ui/utils/csv'; + +function row(value: string | number | boolean | null): string { + return recordsToCsv([{ Value: value }], ['Value']).split('\n')[1]; +} + +describe('CSV formula-injection guard', () => { + it.each([ + ['negative integer', -5, '-5'], + ['negative decimal', -12.5, '-12.5'], + ['positive number', 42, '42'], + ['boolean', true, 'true'], + ])('passes %s through unchanged', (_label, value, expected) => { + expect(row(value)).toBe(expected); + }); + + it.each([ + ['equals', '=SUM(A1:A9)', "'=SUM(A1:A9)"], + ['plus', '+44 20 7946 0000', "'+44 20 7946 0000"], + ['minus text', '-5', "'-5"], + ['at', '@cmd', "'@cmd"], + ['tab', '\tx', "'\tx"], + ['carriage return', '\rx', `"'\rx"`], + ['formula with comma', '=HYPERLINK("http://evil","click")', `"'=HYPERLINK(""http://evil"",""click"")"`], + ])('neutralises string cells starting with %s', (_label, value, expected) => { + expect(row(value)).toBe(expected); + }); + + it('leaves ordinary strings and nulls alone', () => { + expect(row('Acme')).toBe('Acme'); + expect(row(null)).toBe(''); + }); +}); diff --git a/tests/unit/data-comparison-sync.test.tsx b/tests/unit/data-comparison-sync.test.tsx new file mode 100644 index 0000000..c6f69b6 --- /dev/null +++ b/tests/unit/data-comparison-sync.test.tsx @@ -0,0 +1,143 @@ +/** + * Regression for #52: org-compare "Copy to target" must update Changed records + * in place (by target Id) instead of inserting duplicates, and insert only + * Added records. External ID match fields keep using a single upsert. + */ +import { h } from 'preact'; +import { fireEvent, render, screen, waitFor } from '@testing-library/preact'; +import { DataComparisonScreen } from '../../src/ui/screens/DataComparisonScreen'; +import type { SfApi } from '../../src/ui/api/sf'; +import { buildCompareSyncPlan, diffRecords } from '../../src/ui/utils/dataDiff'; + +const SOURCE = '00DSOURCE'; +const TARGET = '00DTARGET'; + +interface Fixture { + fields: Array<{ name: string; label: string; type: string; externalId: boolean }>; + source: Record[]; + target: Record[]; +} + +function makeSf(fixture: Fixture) { + const queries: string[] = []; + const startDataPush = jest.fn(async () => ({ pushId: 'p', strategy: 'rest' as const })); + const sf = { + listOrgs: async () => ({ + orgs: [ + { orgId: SOURCE, instanceUrl: 'https://src.my.salesforce.com', username: 'src@example.com', environment: 'sandbox' }, + { orgId: TARGET, instanceUrl: 'https://tgt.my.salesforce.com', username: 'tgt@example.com', environment: 'sandbox' }, + ], + activeOrgId: SOURCE, + }), + crossOrgDescribeGlobal: async () => ({ sobjects: [{ name: 'Account', label: 'Account', queryable: true }] }), + crossOrgDescribeSObject: async () => ({ name: 'Account', fields: fixture.fields }), + crossOrgQuery: async (orgId: string, soql: string) => { + queries.push(soql); + const rows = orgId === SOURCE ? fixture.source : fixture.target; + return { totalSize: rows.length, done: true, records: rows.map(r => ({ attributes: { type: 'Account' }, ...r })) }; + }, + startDataPush, + } as unknown as SfApi; + return { sf, queries, startDataPush }; +} + +async function runCompareAndCopy(sf: SfApi): Promise { + render(); + await screen.findAllByRole('option', { name: /src\.my\.salesforce\.com/ }); + fireEvent.change(screen.getByLabelText('Source Org'), { target: { value: SOURCE } }); + fireEvent.change(screen.getByLabelText('Target Org'), { target: { value: TARGET } }); + + fireEvent.click(await screen.findByRole('button', { name: 'Load Common Objects' })); + fireEvent.click(await screen.findByRole('button', { name: 'Object to compare' })); + fireEvent.mouseDown(await screen.findByRole('option', { name: /Account/ })); + await screen.findByText(/COMPARE FIELDS/); + + fireEvent.click(screen.getByRole('button', { name: 'Compare' })); + fireEvent.click(await screen.findByRole('button', { name: 'Select All Syncable' })); + fireEvent.click(screen.getByRole('button', { name: /Review 2 for Copy/ })); + + fireEvent.input(await screen.findByLabelText('Type COPY to confirm'), { target: { value: 'COPY' } }); +} + +const baseFields = [ + { name: 'Id', label: 'Account ID', type: 'id', externalId: false }, + { name: 'Name', label: 'Name', type: 'string', externalId: false }, + { name: 'Phone', label: 'Phone', type: 'phone', externalId: false }, +]; + +describe('DataComparisonScreen copy to target (#52)', () => { + test('inserts Added records and updates Changed records by target Id when matching on Name', async () => { + const { sf, queries, startDataPush } = makeSf({ + fields: baseFields, + source: [ + { Id: '001S00000000001', Name: 'Acme', Phone: '111' }, + { Id: '001S00000000002', Name: 'NewCo', Phone: '222' }, + { Id: '001S00000000003', Name: 'Same', Phone: '333' }, + ], + target: [ + { Id: '001T00000000001', Name: 'Acme', Phone: '999' }, + { Id: '001T00000000003', Name: 'Same', Phone: '333' }, + ], + }); + + await runCompareAndCopy(sf); + + // Target Id is queried, but differing Ids are not reported as changes. + expect(queries[0]).toMatch(/^SELECT Name, Phone, Id FROM Account/); + expect(screen.getByText('Δ1 changed')).toBeTruthy(); + expect(screen.getByText('1 unchanged')).toBeTruthy(); + + // Confirmation reflects the split. + expect(screen.getByRole('dialog').textContent).toContain('1 insert, 1 update'); + + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => expect(startDataPush).toHaveBeenCalledTimes(2)); + + expect(startDataPush).toHaveBeenCalledWith(expect.objectContaining({ + orgId: TARGET, objectName: 'Account', operation: 'insert', records: [{ Name: 'NewCo', Phone: '222' }], + })); + expect(startDataPush).toHaveBeenCalledWith(expect.objectContaining({ + orgId: TARGET, objectName: 'Account', operation: 'update', records: [{ Id: '001T00000000001', Phone: '111' }], + })); + }); + + test('keeps a single upsert keyed on the External ID match field', async () => { + const { sf, startDataPush } = makeSf({ + fields: [...baseFields, { name: 'Ext__c', label: 'Ext', type: 'string', externalId: true }], + source: [ + { Id: '001S00000000001', Ext__c: 'A', Name: 'Acme', Phone: '111' }, + { Id: '001S00000000002', Ext__c: 'B', Name: 'NewCo', Phone: '222' }, + ], + target: [ + { Id: '001T00000000001', Ext__c: 'A', Name: 'Acme', Phone: '999' }, + ], + }); + + await runCompareAndCopy(sf); + expect(screen.getByRole('dialog').textContent).toContain('2 upserts by External ID'); + + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => expect(startDataPush).toHaveBeenCalledTimes(1)); + expect(startDataPush).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'upsert', + externalIdField: 'Ext__c', + records: [ + { Ext__c: 'B', Name: 'NewCo', Phone: '222' }, + { Ext__c: 'A', Name: 'Acme', Phone: '111' }, + ], + })); + }); +}); + +describe('buildCompareSyncPlan', () => { + test('skips Changed rows whose target record has no Id rather than inserting them', () => { + const diff = diffRecords( + [{ Name: 'Acme', Phone: '1' }], + [{ Name: 'Acme', Phone: '2' }], + 'Name', ['Phone'], SOURCE, TARGET, 'Account', + ); + const plan = buildCompareSyncPlan(diff, new Set(['Acme']), {}); + expect(plan.jobs).toEqual([]); + expect(plan.skippedKeys).toEqual(['Acme']); + }); +}); diff --git a/tests/unit/data-mapper.test.ts b/tests/unit/data-mapper.test.ts index c250a0c..139b770 100644 --- a/tests/unit/data-mapper.test.ts +++ b/tests/unit/data-mapper.test.ts @@ -59,6 +59,17 @@ describe('DataMapper', () => { expect(result.errors[0].field).toBe('required_field'); }); + it('maps each kept record back to its source row when rows are dropped (#46)', () => { + const mappings: FieldMapping[] = [ + { sourceField: 'name', targetField: 'Name', required: true }, + ]; + + const result = mapper.mapRecords([{ name: 'A' }, { name: '' }, { name: 'C' }, {}, { name: 'E' }], mappings); + + expect(result.mappedRecords).toEqual([{ Name: 'A' }, { Name: 'C' }, { Name: 'E' }]); + expect(result.sourceIndexes).toEqual([0, 2, 4]); + }); + it('makes blank-cell ignore versus clear behavior explicit', () => { const ignored = mapper.mapRecords([{ email: '' }], [{ sourceField: 'email', targetField: 'Email', required: false, blankBehavior: 'ignore', diff --git a/tests/unit/data-validator.test.ts b/tests/unit/data-validator.test.ts index ffd1435..7d13590 100644 --- a/tests/unit/data-validator.test.ts +++ b/tests/unit/data-validator.test.ts @@ -1,4 +1,6 @@ import { DataValidator } from '../../src/data/validators'; +import { DataMapper } from '../../src/data/mappers'; +import { simulatePush } from '../../src/ui/utils/pushDryRun'; import type { SObjectField } from '../../src/core/types/salesforce'; describe('DataValidator', () => { @@ -92,6 +94,84 @@ describe('DataValidator', () => { expect(result.valid).toBe(false); }); }); + + // Regression for #50: reference-lookup mappings produce relationship keys + // (`Account: { External_Key__c: ... }`) that used to be reported as + // `Unknown field "Account"`, deadlocking the guided import. + describe('reference lookups (#50)', () => { + const contactFields = (): SObjectField[] => [ + createField('LastName', 'string', { required: true, nillable: false }), + createField('AccountId', 'reference', { relationshipName: 'Account', referenceTo: ['Account'] }), + createField('Parent__c', 'reference', { relationshipName: 'Parent__r', referenceTo: ['Parent__c'], required: true, nillable: false }), + ]; + + it('accepts relationship keys produced by the mapper for externalId and relatedField lookups', () => { + const mapped = new DataMapper().mapRecords([{ last: 'Doe', accountKey: 'ACME-1', parentName: 'Head Office' }], [ + { sourceField: 'last', targetField: 'LastName', required: true }, + { sourceField: 'accountKey', targetField: 'AccountId', required: false, lookup: { mode: 'externalId', relationshipName: 'Account', matchField: 'External_Key__c' } }, + { sourceField: 'parentName', targetField: 'Parent__c', required: false, lookup: { mode: 'relatedField', relationshipName: 'Parent__r', matchField: 'Name' } }, + ]).mappedRecords; + expect(mapped[0]).toEqual({ LastName: 'Doe', Account: { External_Key__c: 'ACME-1' }, Parent__r: { Name: 'Head Office' } }); + + for (const op of ['insert', 'update', 'upsert'] as const) { + const result = validator.validateRecords(mapped, contactFields(), op); + expect(result.errors).toEqual([]); + } + }); + + it('lets the dry run pass lookup rows instead of flagging every row', () => { + const report = simulatePush([{ LastName: 'Doe', Account: { External_Key__c: 'ACME-1' }, Parent__r: { Name: 'HQ' } }], contactFields(), 'insert'); + expect(report.rows[0]).toEqual(expect.objectContaining({ status: 'ok', reasons: [] })); + }); + + it('derives custom __r names when describe omits relationshipName', () => { + const fields = [createField('Region__c', 'reference', { relationshipName: null, referenceTo: ['Region__c'] })]; + expect(validator.validateRecords([{ Region__r: { Code__c: 'EMEA' } }], fields, 'insert').errors).toEqual([]); + }); + + it('still rejects keys that are neither fields nor relationship names', () => { + const result = validator.validateRecords([{ LastName: 'Doe', Parent__c: 'a01000000000001', Acount: { Key__c: 'x' } }], contactFields(), 'insert'); + expect(result.errors.map(e => e.message)).toEqual(['Record 0: Unknown field "Acount"']); + }); + + it('rejects malformed nested match values', () => { + const base = { LastName: 'Doe', Parent__c: 'a01000000000001' }; + const bad = [ + { ...base, Account: 'ACME-1' }, + { ...base, Account: {} }, + { ...base, Account: { External_Key__c: 'A', Name: 'B' } }, + { ...base, Account: { External_Key__c: ' ' } }, + { ...base, Account: { External_Key__c: { nested: true } } }, + ]; + const result = validator.validateRecords(bad, contactFields(), 'insert'); + expect(result.errors.map(e => e.field)).toEqual(['Account', 'Account', 'Account', 'Account', 'Account']); + }); + + it('applies createable/updateable rules of the underlying reference field', () => { + const fields = [createField('MasterId', 'reference', { relationshipName: 'Master', referenceTo: ['Account'], updateable: false })]; + expect(validator.validateRecords([{ Master: { Key__c: 'x' } }], fields, 'insert').valid).toBe(true); + const result = validator.validateRecords([{ Master: { Key__c: 'x' } }], fields, 'update'); + expect(result.errors[0].message).toContain('"MasterId" (via "Master") is not updateable'); + }); + + it('requires the match field to be an External ID or idLookup field when related metadata is supplied', () => { + const relatedFields = { + Account: [ + createField('External_Key__c', 'string', { externalId: true }), + createField('Name', 'string', { idLookup: true }), + createField('Description', 'textarea'), + ], + }; + const fields = [createField('AccountId', 'reference', { relationshipName: 'Account', referenceTo: ['Account'] })]; + const check = (matchField: string) => + validator.validateRecords([{ Account: { [matchField]: 'v' } }], fields, 'insert', { relatedFields }).errors.map(e => e.message); + + expect(check('External_Key__c')).toEqual([]); + expect(check('Name')).toEqual([]); + expect(check('Description')).toEqual(['Record 0: Field "Account.Description" is not an External ID or idLookup field and cannot be used to match "Account"']); + expect(check('Missing__c')).toEqual(['Record 0: Unknown field "Missing__c" on Account (via "Account")']); + }); + }); }); function createField( diff --git a/tests/unit/import-lookup-validation.test.tsx b/tests/unit/import-lookup-validation.test.tsx new file mode 100644 index 0000000..1deb96f --- /dev/null +++ b/tests/unit/import-lookup-validation.test.tsx @@ -0,0 +1,83 @@ +import { h } from 'preact'; +import { fireEvent, render, screen, waitFor } from '@testing-library/preact'; +import { DataPushScreen } from '../../src/ui/screens/DataPushScreen'; +import { SfApi } from '../../src/ui/api/sf'; +import type { SObjectField } from '../../src/core/types/salesforce'; + +function field(partial: Partial & { name: string }): SObjectField { + return { + label: partial.name, type: 'string', length: 255, required: false, createable: true, updateable: true, + nillable: true, defaultValue: null, externalId: false, unique: false, ...partial, + } as SObjectField; +} + +// Account (the screen's default object) with a self-referencing parent lookup. +const describes: Record = { + Account: [ + field({ name: 'Name', label: 'Account Name' }), + field({ name: 'Parent_Account__c', label: 'Parent Account', type: 'reference', referenceTo: ['Account'], relationshipName: 'Parent_Account__r' }), + field({ name: 'Legacy_Code__c', label: 'Legacy Code' }), + field({ name: 'Account_Key__c', label: 'Account Key', externalId: true }), + ], +}; + +function mockSfApi(matchField: string): void { + const responses: Record = { + describeGlobal: { sobjects: [{ name: 'Account', label: 'Account', createable: true, updateable: true, deletable: true }] }, + listTemplates: [{ + id: 't1', name: 'Account map', objectName: 'Account', + fieldMappings: [ + { sourceField: 'Org', targetField: 'Name', transformation: 'none', required: false }, + { + sourceField: 'Parent', targetField: 'Parent_Account__c', transformation: 'none', required: false, + lookup: { mode: 'externalId', relationshipName: 'Parent_Account__r', matchField }, + }, + ], + }], + getPushHistory: [], getPushTransactions: [], listActivePushes: [], + }; + for (const name of Object.getOwnPropertyNames(SfApi.prototype)) { + if (name === 'constructor' || typeof (SfApi.prototype as unknown as Record)[name] !== 'function') continue; + jest.spyOn(SfApi.prototype as never, name as never).mockImplementation((async () => responses[name] ?? {}) as never); + } + jest.spyOn(SfApi.prototype, 'describeSObject').mockImplementation((async (objectName: string) => ( + { name: objectName, fields: describes[objectName] ?? [], childRelationships: [] } + )) as never); +} + +async function validateWithTemplate(): Promise { + render( + undefined} + onRequestCleanser={() => undefined} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: 'Continue to mapping' })); + fireEvent.click(await screen.findByRole('button', { name: 'Apply: Account map' })); + // The referenced object's describe is fetched once a lookup mapping exists. + await waitFor(() => expect(SfApi.prototype.describeSObject).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: 'Apply mapping and continue' })); + fireEvent.click(await screen.findByRole('button', { name: 'Validate and review' })); +} + +describe('Import validation of relationship lookups (#50)', () => { + beforeEach(() => jest.clearAllMocks()); + + test('rejects a lookup whose match field is not an External ID on the referenced object', async () => { + mockSfApi('Legacy_Code__c'); + await validateWithTemplate(); + expect(await screen.findByText(/"Account\.Legacy_Code__c" is not an External ID or idLookup field/)).toBeInTheDocument(); + }, 30_000); + + test('accepts a lookup that matches on an External ID field', async () => { + mockSfApi('Account_Key__c'); + await validateWithTemplate(); + expect(await screen.findByRole('button', { name: 'Review confirmation' })).toBeInTheDocument(); + expect(screen.queryByText(/not an External ID/)).not.toBeInTheDocument(); + }, 30_000); +}); diff --git a/tests/unit/import-state.test.tsx b/tests/unit/import-state.test.tsx new file mode 100644 index 0000000..7230bdc --- /dev/null +++ b/tests/unit/import-state.test.tsx @@ -0,0 +1,278 @@ +import { h } from 'preact'; +import type { VNode } from 'preact'; +import { useState } from 'preact/hooks'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/preact'; +import { AppRoot } from '../../src/ui/app/AppRoot'; +import { DataPushScreen } from '../../src/ui/screens/DataPushScreen'; +import { SfApi } from '../../src/ui/api/sf'; +import type { SObjectField } from '../../src/core/types/salesforce'; + +function field(name: string, label: string): SObjectField { + return { + name, label, type: 'string', length: 255, required: false, createable: true, updateable: true, + nillable: true, defaultValue: null, externalId: false, unique: false, + }; +} + +const accountFields = [field('Name', 'Account Name'), field('Description', 'Description')]; + +function makeResponses(environment: 'production' | 'sandbox'): Record { + return { + listTabs: [{ tabId: 7, title: 'Salesforce test tab', url: 'https://example.my.salesforce.com/lightning/page/home', hostname: 'example.my.salesforce.com' }], + getContext: { orgId: '00D-test', username: 'tester@example.com', instanceUrl: 'https://example.my.salesforce.com', apiVersion: 'v61.0', environment }, + getUiSettings: { theme: 'light' }, getOnboarding: { completedSteps: [], dismissedAt: 1 }, + getStorageUsage: { bytesInUse: 0, quota: 10_485_760 }, listOrgs: { orgs: [], activeOrgId: null }, + describeGlobal: { sobjects: [{ name: 'Account', label: 'Account', createable: true, updateable: true, deletable: true }] }, + describeSObject: { name: 'Account', fields: accountFields, childRelationships: [] }, + listTemplates: [], listSavedQueries: [], listQueryFolders: [], getPushHistory: [], + getPushTransactions: [], listActivePushes: [], + startDataPush: { pushId: 'push-1', strategy: 'rest' }, + }; +} + +function mockSfApi(responses: Record): void { + for (const name of Object.getOwnPropertyNames(SfApi.prototype)) { + if (name === 'constructor' || typeof (SfApi.prototype as unknown as Record)[name] !== 'function') continue; + jest.spyOn(SfApi.prototype as never, name as never).mockImplementation((async () => responses[name] ?? {}) as never); + } +} + +// jsdom's File lacks .text() in this jest version; the parsers only need these members. +function jsonFile(name: string, records: Array>): File { + const content = JSON.stringify(records); + return { name, size: content.length, type: 'application/json', text: async () => content } as unknown as File; +} + +/** Dispatch a native event carrying `file` (fireEvent does not reach the screen's file handlers here). */ +async function dispatchFile(target: Element, type: 'change' | 'drop', file: File): Promise { + const event = new Event(type, { bubbles: true, cancelable: true }); + if (type === 'drop') Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } }); + else Object.defineProperty(target, 'files', { value: [file], configurable: true }); + await act(async () => { target.dispatchEvent(event); }); +} + +async function navigate(route: string): Promise { + await act(async () => { + window.location.hash = `#${route}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); +} + +/** Deliver a background broadcast to every live MessageBus listener. */ +async function broadcast(type: string, payload: unknown): Promise { + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls.map(call => call[0] as (...args: unknown[]) => unknown); + await act(async () => { + for (const listener of listeners) listener({ type, payload, requestId: `req-${type}` }, {}, () => undefined); + }); +} + +function stepButton(label: string): HTMLButtonElement { + return screen.getByRole('button', { name: new RegExp(`^\\d+\\s*${label}$`) }) as HTMLButtonElement; +} + +/** Drive the staged Import flow from Configure to the confirmation modal. */ +async function advanceToConfirmation(): Promise { + const next = await screen.findByRole('button', { name: 'Continue to mapping' }); + await waitFor(() => expect(next).not.toBeDisabled()); + fireEvent.click(next); + const apply = await screen.findByRole('button', { name: 'Apply mapping and continue' }); + await waitFor(() => expect(apply).not.toBeDisabled()); + fireEvent.click(apply); + fireEvent.click(await screen.findByRole('button', { name: 'Validate and review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Review confirmation' })); +} + +async function uploadOnImport(container: Element, file: File): Promise { + // The lazily loaded screen shows a drop zone without a dataset, or the + // Configure card's file input once one is loaded. + const target = await waitFor(() => { + const found = container.querySelector('main .wl-dropZone, main input[type="file"][accept=".csv,.tsv,.json,.xlsx,.xml"]'); + if (!found) throw new Error('Import upload control not rendered yet'); + return found as HTMLElement; + }); + await dispatchFile(target, target.classList.contains('wl-dropZone') ? 'drop' : 'change', file); + await screen.findAllByText(new RegExp(file.name)); +} + +describe('import state invalidation', () => { + beforeEach(async () => { + jest.clearAllMocks(); + (chrome.runtime.onMessage.addListener as jest.Mock).mockClear(); + await chrome.storage.local.clear(); + window.location.hash = '#home'; + }); + + test('a file uploaded on Import after cleansing another file is pushed as itself (#44)', async () => { + mockSfApi(makeResponses('sandbox')); + const { container } = render(); + await navigate('advanced/cleanse'); + + const cleanserInput = await screen.findByLabelText('Upload CSV or JSON file'); + await dispatchFile(cleanserInput, 'change', jsonFile('file-a.json', [{ Name: 'A1' }, { Name: 'A2' }, { Name: 'A3' }])); + const applyCleanse = await screen.findByRole('button', { name: 'Apply cleanser changes' }); + await waitFor(() => expect(applyCleanse).not.toBeDisabled()); + fireEvent.click(applyCleanse); + fireEvent.click(screen.getByRole('button', { name: 'Send dataset to Data Push' })); + + // The intended Cleanser -> Import hand-off still uses the cleaned rows. + expect(await screen.findByText('Using cleaned records from Cleanser.')).toBeInTheDocument(); + + await uploadOnImport(container, jsonFile('file-b.json', [{ Name: 'B1' }])); + expect(screen.queryByText('Using cleaned records from Cleanser.')).not.toBeInTheDocument(); + + await advanceToConfirmation(); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalled()); + expect((SfApi.prototype.startDataPush as jest.Mock).mock.calls[0][0].records).toEqual([{ Name: 'B1' }]); + }, 30_000); + + test.each(['import', 'advanced/push'])('production pushes from #%s require the typed confirmation phrase (#48)', async (route) => { + mockSfApi(makeResponses('production')); + const { container } = render(); + await navigate(route); + await uploadOnImport(container, jsonFile('prod.json', [{ Name: 'P1' }, { Name: 'P2' }])); + await advanceToConfirmation(); + + expect(await screen.findByRole('dialog', { name: 'Confirm Production Data Push' })).toBeInTheDocument(); + expect(screen.getByLabelText('Type INSERT 2 Account to confirm')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Start Push' })).not.toBeInTheDocument(); + }, 30_000); + + describe('DataPushScreen dataset changes (#51)', () => { + type Dataset = Parameters[0]['dataset']; + + function Harness(props: { initial: Dataset }): VNode { + const [dataset, setDataset] = useState(props.initial); + return ( + undefined} + /> + ); + } + + const original: Dataset = { + sourceRecords: [{ Org: 'Alpha', Code: '1' }, { Org: 'Beta', Code: '2' }, { Org: 'Gamma', Code: '3' }], + headers: ['Org', 'Code'], + filename: 'orgs.json', + format: 'json', + }; + + function targetFor(container: Element, source: string): string { + return container.querySelector(`[aria-label="Target field for ${source}"]`)?.textContent?.replace('▾', '').trim() ?? ''; + } + + test('Retry keeps the restored manual mappings and only re-pushes the failed rows', async () => { + mockSfApi({ + ...makeResponses('sandbox'), + // A saved mapping supplies the manual "Org -> Name" choice that automap would not make. + listTemplates: [{ + id: 't1', name: 'Org map', objectName: 'Account', + fieldMappings: [ + { sourceField: 'Org', targetField: 'Name', transformation: 'none', required: false }, + { sourceField: 'Code', targetField: '', transformation: 'none', required: false }, + ], + }], + }); + const { container } = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Continue to mapping' })); + await waitFor(() => expect(targetFor(container, 'Org')).toBe('(skip)')); + fireEvent.click(await screen.findByRole('button', { name: 'Apply: Org map' })); + expect(targetFor(container, 'Org')).toBe('Account Name'); + + const apply = screen.getByRole('button', { name: 'Apply mapping and continue' }); + fireEvent.click(apply); + fireEvent.click(await screen.findByRole('button', { name: 'Validate and review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Review confirmation' })); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalledTimes(1)); + expect((SfApi.prototype.startDataPush as jest.Mock).mock.calls[0][0].records).toHaveLength(3); + + await broadcast('DATA_PUSH_COMPLETE', { + pushId: 'push-1', totalRecords: 3, processedRecords: 3, failedRecords: 1, status: 'complete', + errors: [{ recordIndex: 1, message: 'DUPLICATE_VALUE' }], + }); + fireEvent.click(await screen.findByRole('button', { name: 'Retry Failed Rows' })); + fireEvent.click(await screen.findByRole('button', { name: 'Generate Retry Dataset' })); + await screen.findByText(/retry-push-1\.json/); + + // The restored manual mapping must survive the dataset swap. + expect(targetFor(container, 'Org')).toBe('Account Name'); + + await advanceToConfirmation(); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalledTimes(2)); + expect((SfApi.prototype.startDataPush as jest.Mock).mock.calls[1][0].records).toEqual([{ Name: 'Beta' }]); + }, 30_000); + + test('Prepare Delete Push still hands the rollback IDs straight to Review', async () => { + mockSfApi({ + ...makeResponses('sandbox'), + getDataPushResult: { ids: ['001A', '001B'], capturedAt: 1 }, + }); + render(); + await advanceToConfirmation(); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalledTimes(1)); + await broadcast('DATA_PUSH_COMPLETE', { pushId: 'push-1', totalRecords: 2, processedRecords: 2, failedRecords: 0, status: 'complete' }); + + fireEvent.click(await screen.findByRole('button', { name: 'Prepare Delete Push' })); + await screen.findAllByText(/rollback-push-1\.json/); + fireEvent.click(stepButton('Review')); + fireEvent.click(await screen.findByRole('button', { name: 'Review confirmation' })); + expect(await screen.findByLabelText('Type DELETE 2 RECORDS to confirm')).toBeInTheDocument(); + }, 30_000); + + test('Retry invalidates the previous mapped records so Review cannot re-push them', async () => { + mockSfApi(makeResponses('sandbox')); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Continue to mapping' })); + const apply = await screen.findByRole('button', { name: 'Apply mapping and continue' }); + await waitFor(() => expect(apply).not.toBeDisabled()); + fireEvent.click(apply); + fireEvent.click(await screen.findByRole('button', { name: 'Validate and review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Review confirmation' })); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalledTimes(1)); + + await broadcast('DATA_PUSH_COMPLETE', { + pushId: 'push-1', totalRecords: 3, processedRecords: 3, failedRecords: 1, status: 'complete', + errors: [{ recordIndex: 1, message: 'DUPLICATE_VALUE' }], + }); + fireEvent.click(await screen.findByRole('button', { name: 'Retry Failed Rows' })); + fireEvent.click(await screen.findByRole('button', { name: 'Generate Retry Dataset' })); + await screen.findByText(/retry-push-1\.json/); + + // Review was reachable for the old dataset; it must not be for the new one. + expect(stepButton('Review')).toBeDisabled(); + expect(stepButton('Mapping')).toBeDisabled(); + expect(await screen.findByRole('button', { name: 'Continue to mapping' })).toBeInTheDocument(); + }, 30_000); + + test('uploading a new file after validation forces the new file through mapping again', async () => { + mockSfApi(makeResponses('sandbox')); + const { container } = render(); + await advanceToConfirmation(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(stepButton('Review')).not.toBeDisabled(); + + await dispatchFile(container.querySelector('input[type="file"]')!, 'change', jsonFile('b.json', [{ Name: 'B1' }])); + await screen.findAllByText(/b\.json/); + + expect(stepButton('Review')).toBeDisabled(); + await advanceToConfirmation(); + fireEvent.click(await screen.findByRole('button', { name: 'Start Push' })); + await waitFor(() => expect(SfApi.prototype.startDataPush).toHaveBeenCalled()); + expect((SfApi.prototype.startDataPush as jest.Mock).mock.calls[0][0].records).toEqual([{ Name: 'B1' }]); + }, 30_000); + }); +}); diff --git a/tests/unit/migrationPush.test.ts b/tests/unit/migrationPush.test.ts new file mode 100644 index 0000000..40e37b7 --- /dev/null +++ b/tests/unit/migrationPush.test.ts @@ -0,0 +1,95 @@ +/** + * Migration push read-back (issues #43 / #90 / #47). + * + * Regression for the PR #110 merge that made the migration poller return a bare + * `string[]`, which the consumer then read as a push result (`result.ids` + * undefined, so every migrated object failed and no ID map was written), and + * for the poller ignoring results whose `ids` is empty (every record failed), + * which stalled the run for the full timeout and then reported success. + */ + +import { awaitPushResult, buildIdMapEntries } from '../../src/ui/utils/migrationPush'; +import type { DataPushResultGetResponse } from '../../src/core/types/messaging'; + +function pushResult(overrides: Partial = {}): DataPushResultGetResponse { + return { + pushId: 'push_1', + objectName: 'Account', + operation: 'insert', + ids: [], + capturedAt: 1, + ...overrides, + }; +} + +describe('awaitPushResult', () => { + it('resolves with the full stored push result, not just the ID list', async () => { + const stored = pushResult({ + ids: ['001T1', '001T3'], + idRecordIndexes: [0, 2], + failedRecords: [{ index: 1, record: {}, error: 'REQUIRED_FIELD_MISSING' }], + }); + const sf = { getDataPushResult: jest.fn().mockResolvedValueOnce(null).mockResolvedValue(stored) }; + + const result = await awaitPushResult(sf, 'push_1', 1_000, 0); + + expect(result).toEqual(stored); + expect(sf.getDataPushResult).toHaveBeenCalledTimes(2); + }); + + it('returns a stored result with no IDs (every record failed) instead of polling until timeout', async () => { + const stored = pushResult({ + ids: [], + failedRecords: [{ index: 0, record: {}, error: 'DUPLICATE_VALUE' }], + }); + const sf = { getDataPushResult: jest.fn().mockResolvedValue(stored) }; + + await expect(awaitPushResult(sf, 'push_1', 1_000, 0)).resolves.toEqual(stored); + expect(sf.getDataPushResult).toHaveBeenCalledTimes(1); + }); + + it('resolves null when no result is stored before the timeout', async () => { + const sf = { getDataPushResult: jest.fn().mockResolvedValue(null) }; + await expect(awaitPushResult(sf, 'push_1', 5, 1)).resolves.toBeNull(); + }); +}); + +describe('buildIdMapEntries', () => { + const source = [ + { Id: '001S1', Name: 'A' }, + { Id: '001S2', Name: 'B' }, + { Id: '001S3', Name: 'C' }, + ]; + + it('pairs IDs with their source rows via idRecordIndexes, even out of order', () => { + const entries = buildIdMapEntries( + source, + { ids: ['001T3', '001T1', '001T2'], idRecordIndexes: [2, 0, 1] }, + 'Account', + 42, + ); + expect(entries).toEqual([ + { sourceId: '001S3', targetId: '001T3', objectName: 'Account', createdAt: 42 }, + { sourceId: '001S1', targetId: '001T1', objectName: 'Account', createdAt: 42 }, + { sourceId: '001S2', targetId: '001T2', objectName: 'Account', createdAt: 42 }, + ]); + }); + + it('skips failed rows so later records keep their own target IDs', () => { + const entries = buildIdMapEntries(source, { ids: ['001T1', '001T3'], idRecordIndexes: [0, 2] }, 'Account'); + expect(entries.map(e => [e.sourceId, e.targetId])).toEqual([ + ['001S1', '001T1'], + ['001S3', '001T3'], + ]); + }); + + it('does not guess a source row for unidentified (-1) or unindexed IDs', () => { + expect(buildIdMapEntries(source, { ids: ['001T1', '001T2'], idRecordIndexes: [-1, 1] }, 'Account') + .map(e => [e.sourceId, e.targetId])).toEqual([['001S2', '001T2']]); + expect(buildIdMapEntries(source, { ids: ['001T1', '001T2'] }, 'Account')).toEqual([]); + }); + + it('returns no entries when every record failed', () => { + expect(buildIdMapEntries(source, { ids: [], idRecordIndexes: [] }, 'Account')).toEqual([]); + }); +}); diff --git a/tests/unit/offscreen-bulk-push.test.ts b/tests/unit/offscreen-bulk-push.test.ts index b7568d7..2532d57 100644 --- a/tests/unit/offscreen-bulk-push.test.ts +++ b/tests/unit/offscreen-bulk-push.test.ts @@ -1,5 +1,6 @@ import { StorageService } from '../../src/services/storage'; import { BulkApiService } from '../../src/services/salesforce/bulk-api'; +import { buildBulkRowIdentity } from '../../src/services/salesforce/bulk-results'; import { runBulkPush } from '../../src/offscreen'; describe('offscreen Bulk push finalization', () => { @@ -45,4 +46,49 @@ describe('offscreen Bulk push finalization', () => { expect((await storage.getPushHistory()).find(entry => entry.id === 'push-offscreen')).toEqual(expect.objectContaining({ successCount: 2 })); expect((await storage.getPushTransactions()).find(tx => tx.pushId === 'push-offscreen')?.rollbackIds).toEqual(['001A', '001B']); }); + + it('broadcasts per-row errors and IDs mapped to input indices (#47)', async () => { + const storage = new StorageService(); + const records = [{ Name: 'Acme' }, { Name: 'Globex' }, { Name: 'Initech' }]; + await storage.setActivePush({ + id: 'push-rows', orgId: '00D', objectName: 'Account', operation: 'insert', + totalRecords: 3, processedRecords: 0, failedRecords: 0, startedAt: 1, + status: 'processing', strategy: 'bulk', bulkJobId: '750yy', resumeSupported: true, + }); + jest.spyOn(BulkApiService.prototype, 'pollJobCompletion').mockResolvedValue({ + id: '750yy', operation: 'insert', object: 'Account', state: 'JobComplete', + numberRecordsProcessed: 3, numberRecordsFailed: 1, createdDate: 'now', jobType: 'V2Ingest', + }); + // Result files echo the uploaded columns and are not in upload order. + jest.spyOn(BulkApiService.prototype, 'getSuccessfulResults').mockResolvedValue([ + { sf__Id: '001C', sf__Created: 'true', sf__Error: '', Name: 'Initech' }, + { sf__Id: '001A', sf__Created: 'true', sf__Error: '', Name: 'Acme' }, + ]); + jest.spyOn(BulkApiService.prototype, 'getFailedResults').mockResolvedValue([ + { sf__Id: '', sf__Created: '', sf__Error: 'DUPLICATE_VALUE:duplicate', Name: 'Globex' }, + ]); + const sendMessage = chrome.runtime.sendMessage as jest.Mock; + sendMessage.mockClear(); + + await runBulkPush({ + type: 'OFFSCREEN_BULK_PUSH', + payload: { + pushId: 'push-rows', jobId: '750yy', instanceUrl: 'https://example.my.salesforce.com', + accessToken: 'session-only', apiVersion: 'v65.0', orgId: '00D', objectName: 'Account', + operation: 'insert', totalRecords: 3, startedAt: 1, rowIdentity: buildBulkRowIdentity(records), + }, + }); + + const complete = sendMessage.mock.calls.map(call => call[0]).find(m => m.type === 'DATA_PUSH_COMPLETE'); + expect(complete.payload).toEqual(expect.objectContaining({ + status: 'complete', + failedRecords: 1, + errors: [{ recordIndex: 1, message: 'DUPLICATE_VALUE:duplicate' }], + ids: ['001C', '001A'], + idRecordIndexes: [2, 0], + })); + expect(await storage.getPushResult('push-rows')).toEqual(expect.objectContaining({ + ids: ['001C', '001A'], idRecordIndexes: [2, 0], + })); + }); }); diff --git a/tests/unit/push-dry-run.test.ts b/tests/unit/push-dry-run.test.ts index 84b7438..a0fafd1 100644 --- a/tests/unit/push-dry-run.test.ts +++ b/tests/unit/push-dry-run.test.ts @@ -110,3 +110,27 @@ describe('simulatePush', () => { expect(lines[2]).toMatch(/^2,error,/); }); }); + +describe('simulatePush relationship lookups (#50)', () => { + const contactFields: SObjectField[] = [ + field({ name: 'LastName', type: 'string', length: 80 }), + field({ name: 'AccountId', type: 'reference', referenceTo: ['Account'], relationshipName: 'Account' }), + ]; + const relatedFields = { + Account: [ + field({ name: 'Legacy_Code__c', type: 'string' }), + field({ name: 'Account_Key__c', type: 'string', externalId: true }), + ], + }; + + it('fails rows whose lookup matches on a non-External ID field of the referenced object', () => { + const report = simulatePush([{ LastName: 'Doe', Account: { Legacy_Code__c: 'A-1' } }], contactFields, 'insert', { relatedFields }); + expect(report.failed).toBe(1); + expect(report.rows[0].reasons.join(' ')).toMatch(/not an External ID or idLookup field/); + }); + + it('passes rows whose lookup matches on an External ID field', () => { + const report = simulatePush([{ LastName: 'Doe', Account: { Account_Key__c: 'A-1' } }], contactFields, 'insert', { relatedFields }); + expect(report.failed).toBe(0); + }); +}); diff --git a/tests/unit/push-results.test.ts b/tests/unit/push-results.test.ts index 9f93a60..b43f6cd 100644 --- a/tests/unit/push-results.test.ts +++ b/tests/unit/push-results.test.ts @@ -1,6 +1,6 @@ import { StorageService } from '../../src/services/storage'; import { STORAGE_KEYS } from '../../src/core/constants'; -import { buildPushOutcomeDatasets } from '../../src/ui/utils/pushRetry'; +import { buildPushOutcomeDatasets, buildRetryDataset, countMappingDroppedRows } from '../../src/ui/utils/pushRetry'; describe('Push Results (Session)', () => { test('caps stored push results to 20 (keeps most recent by capturedAt)', async () => { @@ -35,7 +35,7 @@ describe('push outcome downloads', () => { const result = buildPushOutcomeDatasets( [{ Name: 'Good' }, { Name: 'Bad' }, { Name: 'Also good' }], [{ recordIndex: 1, message: 'Missing field' }, { recordIndex: 1, message: 'Invalid value' }], - ['001-good', '001-also'], + { ids: ['001-good', '001-also'], idRecordIndexes: [0, 2] }, ); expect(result.success.records).toEqual([ { Name: 'Good', WaveLinkRecordId: '001-good' }, @@ -45,5 +45,126 @@ describe('push outcome downloads', () => { Name: 'Bad', WaveLinkError: 'Missing field; Invalid value', WaveLinkSourceRow: 3, }]); }); + + test('attaches IDs by record index, not position, when results arrive out of order (#47)', () => { + // Bulk results / parallel REST batches: IDs come back in a different order than the input, + // and an updating upsert (HTTP 204) returns no ID for row 1. + const result = buildPushOutcomeDatasets( + [{ Name: 'A' }, { Name: 'B' }, { Name: 'C' }, { Name: 'D' }], + [{ recordIndex: 2, message: 'DUPLICATE_VALUE' }], + { ids: ['001-D', '001-A'], idRecordIndexes: [3, 0] }, + ); + expect(result.success.records).toEqual([ + { Name: 'A', WaveLinkRecordId: '001-A' }, + { Name: 'B' }, + { Name: 'D', WaveLinkRecordId: '001-D' }, + ]); + expect(result.error.records).toEqual([{ Name: 'C', WaveLinkError: 'DUPLICATE_VALUE', WaveLinkSourceRow: 4 }]); + }); + + test('never zips IDs positionally when no record indexes are available (#47)', () => { + const result = buildPushOutcomeDatasets( + [{ Name: 'A' }, { Name: 'B' }], + [{ recordIndex: 0, message: 'Failed' }], + { ids: ['001-unknown'] }, + ); + expect(result.success.records).toEqual([{ Name: 'B' }]); + expect(result.success.headers).toEqual(['Name']); + }); +}); + +describe('source-row translation after mapping drops rows (#46)', () => { + // Source row 1 failed mapping, so the pushed array is [row0, row2, row3, row4]. + const sourceRecords = [{ Name: 'R0' }, { Name: 'R1-unmapped' }, { Name: 'R2' }, { Name: 'R3' }, { Name: 'R4' }]; + const sourceIndexes = [0, 2, 3, 4]; + // Pushed positions 1 and 3 failed, i.e. source rows 2 and 4. + const pushErrors = [{ recordIndex: 1, message: 'Bad R2' }, { recordIndex: 3, message: 'Bad R4' }]; + + test('retry dataset targets the source rows that actually failed', () => { + const retry = buildRetryDataset(sourceRecords, pushErrors, sourceIndexes); + expect(retry.records).toEqual([{ Name: 'R2' }, { Name: 'R4' }]); + expect(retry.originalIndices).toEqual([2, 4]); + expect(retry.errorMap).toEqual(new Map([[0, 'Bad R2'], [1, 'Bad R4']])); + }); + + test('outcome files blame the right rows and attach IDs to the right rows', () => { + const result = buildPushOutcomeDatasets( + sourceRecords, + pushErrors, + { ids: ['001-R0', '001-R3'], idRecordIndexes: [0, 2] }, + sourceIndexes, + [{ recordIndex: 1, message: 'Required field "Name" is missing' }], + ); + expect(result.error.records).toEqual([ + { Name: 'R1-unmapped', WaveLinkError: 'Not pushed (mapping error): Required field "Name" is missing', WaveLinkSourceRow: 3 }, + { Name: 'R2', WaveLinkError: 'Bad R2', WaveLinkSourceRow: 4 }, + { Name: 'R4', WaveLinkError: 'Bad R4', WaveLinkSourceRow: 6 }, + ]); + // The row dropped at mapping was never pushed, so it is not reported as a success. + expect(result.success.records).toEqual([ + { Name: 'R0', WaveLinkRecordId: '001-R0' }, + { Name: 'R3', WaveLinkRecordId: '001-R3' }, + ]); + }); }); +describe('rows dropped at mapping in Import Results', () => { + const sourceRecords = [{ Name: 'A' }, { Name: '' }, { Name: 'C' }, { Name: '' }]; + const sourceIndexes = [0, 2]; + const mappingErrors = [ + { recordIndex: 1, message: 'Required field "Name" is missing' }, + { recordIndex: 3, message: 'Required field "Name" is missing' }, + { recordIndex: 3, message: 'Invalid date "x"' }, + ]; + + test('appear in the error file with their mapping errors and source row numbers', () => { + const result = buildPushOutcomeDatasets( + sourceRecords, + [], + { ids: ['001A', '001C'], idRecordIndexes: [0, 1] }, + sourceIndexes, + mappingErrors, + ); + expect(result.success.records).toEqual([ + { Name: 'A', WaveLinkRecordId: '001A' }, + { Name: 'C', WaveLinkRecordId: '001C' }, + ]); + expect(result.error.records).toEqual([ + { Name: '', WaveLinkError: 'Not pushed (mapping error): Required field "Name" is missing', WaveLinkSourceRow: 3 }, + { Name: '', WaveLinkError: 'Not pushed (mapping error): Required field "Name" is missing; Invalid date "x"', WaveLinkSourceRow: 5 }, + ]); + expect(result.error.headers).toEqual(['Name', 'WaveLinkError', 'WaveLinkSourceRow']); + }); + + test('are reported even without mapping error detail', () => { + const result = buildPushOutcomeDatasets(sourceRecords, [], { ids: [] }, sourceIndexes); + expect(result.error.records.map(r => r.WaveLinkSourceRow)).toEqual([3, 5]); + expect(result.success.records).toHaveLength(2); + }); + + test('are interleaved with push failures in source order', () => { + const result = buildPushOutcomeDatasets( + sourceRecords, + [{ recordIndex: 1, message: 'DUPLICATE_VALUE' }], + { ids: ['001A'], idRecordIndexes: [0] }, + sourceIndexes, + mappingErrors, + ); + expect(result.error.records.map(r => [r.WaveLinkSourceRow, r.WaveLinkError])).toEqual([ + [3, 'Not pushed (mapping error): Required field "Name" is missing'], + [4, 'DUPLICATE_VALUE'], + [5, 'Not pushed (mapping error): Required field "Name" is missing; Invalid date "x"'], + ]); + }); + + test('are not re-sent by Retry Failed Rows', () => { + const retry = buildRetryDataset(sourceRecords, [{ recordIndex: 1, message: 'DUPLICATE_VALUE' }], sourceIndexes); + expect(retry.originalIndices).toEqual([2]); + expect(retry.records).toEqual([{ Name: 'C' }]); + }); + + test('are counted for the error-file button and retry notice', () => { + expect(countMappingDroppedRows(4, sourceIndexes)).toBe(2); + expect(countMappingDroppedRows(4, undefined)).toBe(0); + }); +}); diff --git a/tests/unit/push-row-identity.test.ts b/tests/unit/push-row-identity.test.ts new file mode 100644 index 0000000..3db1343 --- /dev/null +++ b/tests/unit/push-row-identity.test.ts @@ -0,0 +1,83 @@ +/** + * REST push results carry per-row record indices for their IDs (#47). + * + * Exercises DATA_PUSH_START in src/background/index.ts through the chrome.runtime.onMessage + * listener with the Salesforce client mocked at the prototype boundary. + */ + +import { StorageService } from '../../src/services/storage'; +import { SalesforceAuth } from '../../src/services/salesforce/auth'; +import { SalesforceApiClient } from '../../src/services/salesforce/api-client'; +import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; +import type { SalesforceOrg } from '../../src/core/types/salesforce'; + +// Import background module to register handlers on the MessageBus. +import '../../src/background/index'; + +/** Dispatch like Chrome does: offer the message to every registered listener until one claims it. */ +function send(type: string, payload: unknown): Promise { + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls.map(call => call[0]); + const message = { type, payload, requestId: `req-${Date.now()}`, timestamp: Date.now(), source: 'popup' } as ExtensionMessage; + return new Promise((resolve, reject) => { + if (!listeners.some(listener => listener(message, {}, resolve) === true)) reject(new Error(`No handler for ${type}`)); + }); +} + +async function waitFor(read: () => Promise): Promise { + for (let i = 0; i < 200; i++) { + const value = await read(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 5)); + } + throw new Error('Timed out waiting for push result'); +} + +const org: SalesforceOrg = { + id: '00Dxx0000000001', orgId: '00Dxx0000000001', instanceUrl: 'https://example.my.salesforce.com', + environment: 'sandbox', username: 'u@example.test', displayName: 'Test', accessToken: 'token', + tokenExpiresAt: Date.now() + 3_600_000, apiVersion: 'v65.0', connectedAt: 1, lastUsedAt: 1, +}; + +describe('REST push row identity (#47)', () => { + beforeEach(async () => { + await chrome.storage.local.clear(); + await chrome.storage.session.clear(); + jest.restoreAllMocks(); + await new StorageService().saveOrg(org); + jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockImplementation(async o => o); + }); + + it('records the input index of each upsert ID, including updates that return no ID', async () => { + jest.spyOn(SalesforceApiClient.prototype, 'composite').mockResolvedValue({ + compositeResponse: [ + { referenceId: 'r0', httpStatusCode: 201, httpHeaders: {}, body: { id: '001A', success: true, created: true } }, + // Upsert that matched an existing record: 204 No Content, no ID. + { referenceId: 'r1', httpStatusCode: 204, httpHeaders: {}, body: null }, + { referenceId: 'r2', httpStatusCode: 400, httpHeaders: {}, body: [{ message: 'Bad value', errorCode: 'INVALID' }] }, + { referenceId: 'r3', httpStatusCode: 201, httpHeaders: {}, body: { id: '001D', success: true, created: true } }, + ], + }); + const sendMessage = chrome.runtime.sendMessage as jest.Mock; + sendMessage.mockClear(); + + const start = await send('DATA_PUSH_START', { + orgId: org.orgId, objectName: 'Account', operation: 'upsert', externalIdField: 'Key__c', useBulkApi: false, + records: [{ Key__c: 'A' }, { Key__c: 'B' }, { Key__c: 'C' }, { Key__c: 'D' }], + }); + expect(start.success).toBe(true); + const { pushId } = start.data as { pushId: string }; + + const stored = await waitFor(() => new StorageService().getPushResult(pushId)); + expect(stored.ids).toEqual(['001A', '001D']); + expect(stored.idRecordIndexes).toEqual([0, 3]); + + const complete = await waitFor(async () => sendMessage.mock.calls + .map(call => call[0]) + .find(m => m?.type === 'DATA_PUSH_COMPLETE' && m.payload.pushId === pushId)); + expect(complete.payload).toEqual(expect.objectContaining({ + errors: [{ recordIndex: 2, message: 'Bad value' }], + ids: ['001A', '001D'], + idRecordIndexes: [0, 3], + })); + }); +}); diff --git a/tests/unit/soqlBuilder.test.ts b/tests/unit/soqlBuilder.test.ts index 458ff45..b694d88 100644 --- a/tests/unit/soqlBuilder.test.ts +++ b/tests/unit/soqlBuilder.test.ts @@ -117,7 +117,7 @@ describe('buildSoql', () => { ], }); const result = buildSoql(state); - expect(result).toContain('WHERE Email = NULL'); + expect(result).toContain('WHERE Email = null'); }); it('builds IS NOT NULL condition', () => { @@ -127,7 +127,7 @@ describe('buildSoql', () => { ], }); const result = buildSoql(state); - expect(result).toContain('WHERE Email != NULL'); + expect(result).toContain('WHERE Email != null'); }); it('skips conditions with empty field or operator', () => { diff --git a/tests/unit/storage-quota.test.ts b/tests/unit/storage-quota.test.ts new file mode 100644 index 0000000..34a0eae --- /dev/null +++ b/tests/unit/storage-quota.test.ts @@ -0,0 +1,66 @@ +/** + * StorageService quota guard (PR #114, issues #56/#63/#94/#99). + * + * The guard must never stop a legitimate write: eviction is best-effort, it + * must not recurse back into the guarded write path, and an unavailable usage + * API must not turn every write into a StorageError. + */ + +import { storageMock } from '../mocks/chromeMock'; +import { StorageService } from '../../src/services/storage'; +import { STORAGE_KEYS } from '../../src/core/constants'; +import type { ActivePush } from '../../src/core/types/storage'; + +const GB = 1024 * 1024 * 1024; +const getBytesInUse = chrome.storage.local.getBytesInUse as jest.Mock; + +function oldTerminalPush(id: string): ActivePush { + const longAgo = Date.now() - 10 * 24 * 60 * 60 * 1000; + return { + id, + orgId: 'org', + objectName: 'Account', + operation: 'insert', + status: 'complete', + totalRecords: 1, + processedRecords: 1, + failedRecords: 0, + startedAt: longAgo, + updatedAt: longAgo, + } as ActivePush; +} + +beforeEach(() => { + storageMock.local = {}; + storageMock.session = {}; + getBytesInUse.mockClear(); +}); + +afterEach(() => { + getBytesInUse.mockImplementation(() => Promise.resolve(JSON.stringify(storageMock.local).length)); +}); + +describe('StorageService quota guard', () => { + it('evicts old terminal pushes once and still writes when usage stays above the threshold', async () => { + storageMock.local[STORAGE_KEYS.ACTIVE_PUSHES] = { old: oldTerminalPush('old') }; + // Usage never drops (e.g. other large keys), so eviction cannot reach its target. + getBytesInUse.mockImplementation(() => Promise.resolve(0.95 * GB)); + + const svc = new StorageService(); + await svc.setUiSettings({ theme: 'dark' }); + + expect((storageMock.local[STORAGE_KEYS.UI_SETTINGS] as { theme: string }).theme).toBe('dark'); + expect(storageMock.local[STORAGE_KEYS.ACTIVE_PUSHES]).toEqual({}); + // Bounded work: one guard check plus a handful of eviction checks, no recursion. + expect(getBytesInUse.mock.calls.length).toBeLessThan(10); + }); + + it('still writes when the usage API is unavailable', async () => { + getBytesInUse.mockImplementation(() => Promise.reject(new Error('getBytesInUse is not supported'))); + + const svc = new StorageService(); + await svc.setUiSettings({ theme: 'light' }); + + expect((storageMock.local[STORAGE_KEYS.UI_SETTINGS] as { theme: string }).theme).toBe('light'); + }); +});