From bd8042cf33c7aab518839481dc14b83365d698f7 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:25:26 +0100 Subject: [PATCH 01/23] fix: validate relationship lookup keys instead of rejecting them (#50) Reference-lookup mappings in externalId/relatedField mode produce keys such as `Account: { External_Key__c: ... }`. The validator only knew the reference field name (`AccountId`), so every row failed with `Unknown field "Account"` and the guided import could never be confirmed. The validator now resolves relationship keys via the reference field's relationshipName (falling back to `__c` -> `__r` / `FooId` -> `Foo`, as the import UI does), applies the reference field's createable/updateable rules, requires exactly one non-empty scalar match value, and treats a required reference field as supplied when its relationship key is present. Callers can optionally pass describe fields of the referenced object to require an External ID or idLookup match field. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/types/salesforce.ts | 2 + src/data/validators/index.ts | 104 +++++++++++++++++++++++++++++- tests/unit/data-validator.test.ts | 80 +++++++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/src/core/types/salesforce.ts b/src/core/types/salesforce.ts index d1b43bc..55443ed 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; } 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/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( From 3dd484fe24a3d1e87a9996f51ad77deca1a144a7 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:25:26 +0100 Subject: [PATCH 02/23] fix: update changed records by target Id on compare sync (#52) Copy to target sent Added and Changed records as a single insert when the match field was not an External ID, duplicating every Changed record. The comparison now also selects Id (never compared), inserts only Added records, and updates Changed records by the target record's Id with just the differing fields. External ID match fields keep the single upsert. The typed COPY confirmation now shows the insert/update (or upsert) counts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/screens/DataComparisonScreen.tsx | 70 ++++++----- src/ui/utils/dataDiff.ts | 70 +++++++++++ tests/unit/data-comparison-sync.test.tsx | 143 +++++++++++++++++++++++ 3 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 tests/unit/data-comparison-sync.test.tsx 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/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/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']); + }); +}); From db82d7cae8c4e2f3573b47d8e4c3fca95a8cff1f Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:31:27 +0100 Subject: [PATCH 03/23] fix: reset import state on dataset change and gate advanced push (#44, #48, #51) - AppRoot: route every dataset swap through replaceDataset, which also clears rows cleaned from the previous file, so a file uploaded on Import (or a retry/rollback/snapshot dataset) is pushed as itself rather than as the last cleansed file. The Cleanser -> Import hand-off is unchanged (#44). - AppRoot: pass the org context to the Advanced -> Data Push screen so the production typed confirmation and warnings apply there too (#48). - DataPushScreen: invalidate mapped records, validation, dry run and the reachable stages whenever the dataset or its cleaned rows change, and let Retry / Prepare Delete Push flag their restored state so the automap effect no longer overwrites restored manual mappings (#51). - Regression tests drive the real AppRoot and DataPushScreen flows. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/app/AppRoot.tsx | 38 ++-- src/ui/screens/DataPushScreen.tsx | 46 ++++- tests/unit/import-state.test.tsx | 278 ++++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 tests/unit/import-state.test.tsx 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/screens/DataPushScreen.tsx b/src/ui/screens/DataPushScreen.tsx index eddec99..d7624e8 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'; @@ -183,6 +183,11 @@ export function DataPushScreen(props: { 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; @@ -285,6 +290,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 +346,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 => { @@ -522,9 +555,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'], }); @@ -552,10 +587,13 @@ export function DataPushScreen(props: { try { const retryData = buildRetryDataset(lastPushConfig.sourceRecords, pushErrors); - // 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, }); 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); + }); +}); From f896107eace2412afabc4bea6a6ce59baabb623f Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:33:18 +0100 Subject: [PATCH 04/23] test: mock chrome storage quota, alarms, and commands APIs StorageService.setLocal now checks getBytesInUse/getManifest before every write and the background module registers chrome.commands/chrome.alarms listeners at import time. The shared chrome mock lacked these, so every local-storage write and every background-module import threw in tests (offscreen-bulk-push, push-checkpoints, migrationStorage suites failed on main). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/mocks/chromeMock.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/mocks/chromeMock.ts b/tests/mocks/chromeMock.ts index 0e2b213..dd9ce32 100644 --- a/tests/mocks/chromeMock.ts +++ b/tests/mocks/chromeMock.ts @@ -27,8 +27,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) => { @@ -66,6 +76,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) => { From bec7b2a85cacd2b63f3eaac8e30ce12a51a31504 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:33:29 +0100 Subject: [PATCH 05/23] fix: per-row bulk results and source-row index mapping (#45, #46, #47) Row identity is now carried end to end instead of being reconstructed by positional zips: - #47 Bulk: after a job completes, both successfulResults and failedResults are fetched and mapped back to input indices by fingerprinting the cells we uploaded (Bulk 2.0 rejects synthetic key columns and does not promise result order). Completion payloads, push results, and history now carry per-row errors plus ids/idRecordIndexes, so Retry Failed Rows and the error file work for Bulk pushes. Unidentifiable rows report index -1 rather than a guess. - #47 REST: each returned ID is recorded with its input index, so parallel batches and HTTP 204 upsert updates (no ID) no longer shift IDs onto the wrong rows. buildPushOutcomeDatasets attaches IDs only via idRecordIndexes. - #46: DataMapper returns sourceIndexes (mapped -> source position); retry and outcome builders translate push indices through it, and rows dropped at mapping are no longer reported as successes. - #45: the ingest CSV header is the union of keys across all records, explicit null is sent as #N/A so blank-means-clear works on Bulk, and values with \r are quoted. Bulk result getters now reject non-OK responses. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/background/index.ts | 68 ++++++++---- src/core/types/messaging.ts | 7 ++ src/core/types/offscreen.ts | 2 + src/core/types/storage.ts | 6 ++ src/data/mappers/index.ts | 10 +- src/offscreen/index.ts | 13 ++- src/services/salesforce/bulk-api.ts | 49 +++++++-- src/services/salesforce/bulk-results.ts | 135 ++++++++++++++++++++++++ src/ui/screens/DataPushScreen.tsx | 17 ++- src/ui/utils/pushRetry.ts | 60 ++++++++--- tests/unit/bulk-ingest.test.ts | 134 +++++++++++++++++++++++ tests/unit/data-mapper.test.ts | 11 ++ tests/unit/offscreen-bulk-push.test.ts | 46 ++++++++ tests/unit/push-results.test.ts | 63 ++++++++++- tests/unit/push-row-identity.test.ts | 83 +++++++++++++++ 15 files changed, 648 insertions(+), 56 deletions(-) create mode 100644 src/services/salesforce/bulk-results.ts create mode 100644 tests/unit/bulk-ingest.test.ts create mode 100644 tests/unit/push-row-identity.test.ts diff --git a/src/background/index.ts b/src/background/index.ts index 879c9b5..44d5046 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -24,6 +24,7 @@ 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 { queryAllRecords, deriveColumns } from '../services/salesforce/queryAll'; import { captureViaOffscreen, runBulkPushViaOffscreen } from './offscreen'; import { buildUpsertSubrequests, parseUpsertCompositeResponse } from '../services/salesforce/composite-upsert'; @@ -1357,6 +1358,9 @@ async function executeRestPush( let failedRecords = 0; const errors: Array<{ recordIndex: number; message: string }> = []; 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 +1369,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 +1421,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 +1446,7 @@ async function executeRestPush( processed: batch.length, failed: batch.length, ids: [], + idIndexes: [], errors: outErrors, }, }; @@ -1446,8 +1456,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 +1471,7 @@ async function executeRestPush( processed: batch.length, failed: built.skipped.length + parsed.failures.length, ids, + idIndexes, errors: outErrors, }, }; @@ -1478,6 +1493,7 @@ async function executeRestPush( } const ids: string[] = []; + const idIndexes: number[] = []; const outErrors: Array<{ recordIndex: number; message: string }> = []; let failed = 0; @@ -1497,6 +1513,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 +1526,7 @@ async function executeRestPush( processed: expected, failed, ids, + idIndexes, errors: outErrors, }, }; @@ -1523,6 +1541,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 +1609,7 @@ async function executeRestPush( objectName: payload.objectName, operation: payload.operation, ids: successfulIds, + idRecordIndexes: successfulIdIndexes, capturedAt: completedAt, failedRecords: failedRecordData.length > 0 ? failedRecordData : undefined, }; @@ -1618,6 +1638,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 +1703,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 +1723,7 @@ async function executeBulkPush( totalRecords: payload.records.length, startedAt: ctx.startedAt, externalIdField: payload.externalIdField, + rowIdentity, }); return; } catch { @@ -1721,18 +1746,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 +1763,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 +1773,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 +1785,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 +1901,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 +1919,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 +1928,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 +1957,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 +2080,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, }, diff --git a/src/core/types/messaging.ts b/src/core/types/messaging.ts index f44fb1d..a2e3582 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 }>; 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/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..d69e188 100644 --- a/src/data/mappers/index.ts +++ b/src/data/mappers/index.ts @@ -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[]; } @@ -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 }; } /** 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..e95ad1d 100644 --- a/src/services/salesforce/bulk-api.ts +++ b/src/services/salesforce/bulk-api.ts @@ -47,6 +47,37 @@ 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; + const str = String(val); + // Security (#66): neutralize formula-injection payloads in upload CSV. + if (/^[=+\-@\t\r]/.test(str)) { + return `'${str}`; + } + return str; +} + /** * BulkApiService manages Bulk API 2.0 ingest jobs. */ @@ -182,6 +213,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 +232,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); } @@ -275,19 +312,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-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/ui/screens/DataPushScreen.tsx b/src/ui/screens/DataPushScreen.tsx index eddec99..df7a4be 100644 --- a/src/ui/screens/DataPushScreen.tsx +++ b/src/ui/screens/DataPushScreen.tsx @@ -171,6 +171,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,6 +182,8 @@ 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[]; mappings: FieldMapping[]; } | null>(null); const busRef = useRef(null); @@ -391,6 +395,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); @@ -533,6 +538,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,7 +556,7 @@ export function DataPushScreen(props: { } try { - const retryData = buildRetryDataset(lastPushConfig.sourceRecords, pushErrors); + const retryData = buildRetryDataset(lastPushConfig.sourceRecords, pushErrors, lastPushConfig.sourceIndexes); // Load retry dataset props.onDataset({ @@ -575,7 +581,12 @@ 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, + ); const selected = datasets[kind]; await exportRecords(selected.records, selected.headers, { format: 'csv', @@ -1209,6 +1220,7 @@ export function DataPushScreen(props: { // Save push config for retry setLastPushConfig({ sourceRecords, + sourceIndexes: mappedSourceIndexes ?? undefined, mappings: [...mappings], }); setToast({ title: 'Push Started', body: `${res.strategy.toUpperCase()} - ${res.pushId}` }); @@ -1295,6 +1307,7 @@ export function DataPushScreen(props: { // Save push config for retry setLastPushConfig({ sourceRecords, + sourceIndexes: mappedSourceIndexes ?? undefined, mappings: [...mappings], }); setToast({ title: 'Push Started', body: `${res.strategy.toUpperCase()} - ${res.pushId}` }); diff --git a/src/ui/utils/pushRetry.ts b/src/ui/utils/pushRetry.ts index d242749..ae72829 100644 --- a/src/ui/utils/pushRetry.ts +++ b/src/ui/utils/pushRetry.ts @@ -16,34 +16,64 @@ 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; +} + +/** + * 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 were never pushed and + * appear in neither file. + */ export function buildPushOutcomeDatasets( originalRecords: Record[], errors: Array<{ recordIndex: number; message: string }>, - successfulIds: string[] = [], + rowIds: { ids: string[]; idRecordIndexes?: number[] } = { ids: [] }, + sourceIndexes?: number[], ): 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]); } - let successIndex = 0; + 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 pushedSourceIndexes = sourceIndexes ?? originalRecords.map((_, i) => i); const successRecords: Record[] = []; const errorRecords: Record[] = []; - originalRecords.forEach((record, recordIndex) => { + for (const recordIndex of pushedSourceIndexes) { + if (!inSource(recordIndex)) continue; + const record = originalRecords[recordIndex]; 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, @@ -56,16 +86,20 @@ export function buildPushOutcomeDatasets( * Builds a retry dataset containing only the records that failed in the original push. * * @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 +114,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/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/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/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-results.test.ts b/tests/unit/push-results.test.ts index 9f93a60..0063bf4 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 } 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,64 @@ 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, + ); + expect(result.error.records).toEqual([ + { 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' }, + ]); + }); }); 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], + })); + }); +}); From 9f6f7b3a87c2797e253b4733a0278b6f04459d7b Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:33:39 +0100 Subject: [PATCH 06/23] fix: restore typecheck for token-refresh payload and migration ID pairing main did not typecheck: OFFSCREEN_TOKEN_REFRESH was a MessageType without a PayloadMap entry, and MigrationWorkspaceScreen read failedRecords/ids off the string[] its awaitPushResult returned. awaitPushResult now returns the stored result and target IDs are paired with source rows through idRecordIndexes (#47) instead of a positional zip. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/types/messaging.ts | 1 + src/ui/screens/MigrationWorkspaceScreen.tsx | 25 +++++++++------------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/core/types/messaging.ts b/src/core/types/messaging.ts index a2e3582..307e4c2 100644 --- a/src/core/types/messaging.ts +++ b/src/core/types/messaging.ts @@ -560,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/ui/screens/MigrationWorkspaceScreen.tsx b/src/ui/screens/MigrationWorkspaceScreen.tsx index ca95c2d..3fbfdc3 100644 --- a/src/ui/screens/MigrationWorkspaceScreen.tsx +++ b/src/ui/screens/MigrationWorkspaceScreen.tsx @@ -14,21 +14,22 @@ import type { JSX } from 'preact'; import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import type { SfApi } from '../api/sf'; +import type { DataPushResultGetResponse } from '../../core/types/messaging'; 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. */ +/** Poll for a completed push result. Resolves with the stored result, or null on timeout. */ async function awaitPushResult( sf: SfApi, pushId: string, timeoutMs = 5 * 60_000, intervalMs = 1_000, -): Promise { +): 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; + if (res && Array.isArray(res.ids) && res.ids.length > 0) return res; await new Promise((r) => setTimeout(r, intervalMs)); } return null; @@ -220,21 +221,17 @@ 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); - } - + // Pair each new ID with the source row that produced it; IDs can arrive out of input + // order, so they are never zipped positionally (#47). 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++]; + result.ids.forEach((targetId, k) => { + const recordIndex = result.idRecordIndexes?.[k] ?? -1; + const source = recordIndex >= 0 ? queryResult.records[recordIndex] as Record | undefined : undefined; + const sourceId = source?.Id as string | undefined; if (sourceId && targetId) { entries.push({ sourceId, targetId, objectName: obj.objectName, createdAt: Date.now() }); } - } + }); // Add to local map for subsequent objects for (const entry of entries) { From fd8df52cbaf06d4898b04b894b59335c17f601fe Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:31:36 +0100 Subject: [PATCH 07/23] test(soql): expect lowercase null literal for IS [NOT] NULL PR #107 (issue #93) intentionally changed buildSoql to emit `= null` / `!= null`, the documented SOQL form, but did not update the two builder tests that still asserted the old `= NULL` output. The expectation is obsolete; the assertions are kept and pointed at the new output. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/soqlBuilder.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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', () => { From b93ff8b2eeb7dc79ce87502bb7f1432a96fc448d Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:33:53 +0100 Subject: [PATCH 08/23] fix(lint): clear the seven lint errors introduced by #112-#123 - background: drop SalesforceApiClient import left unused by #116 and make y/mo const in computeNextRunAt (#112). - storage: drop SCHEMA_CACHE_TTL import added unused by #114. - ExportModal: remove the redundant `\[` escape in the sheet-name regex (#119); the character class is unchanged. - FieldRecommendations: remove redundant role="list" on
    (#123). - SoqlPreview: remove role="textbox"/aria-readonly from the read-only
     (#123). It announced a text field that cannot be focused or
      edited; the aria-label is kept.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) 
    ---
     src/background/index.ts                         | 4 ++--
     src/services/storage/index.ts                   | 2 +-
     src/ui/components/ExportModal.tsx               | 2 +-
     src/ui/components/FieldRecommendations.tsx      | 2 +-
     src/ui/components/query-builder/SoqlPreview.tsx | 2 +-
     5 files changed, 6 insertions(+), 6 deletions(-)
    
    diff --git a/src/background/index.ts b/src/background/index.ts
    index 44d5046..94ade77 100644
    --- a/src/background/index.ts
    +++ b/src/background/index.ts
    @@ -21,7 +21,6 @@
     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';
    @@ -2285,7 +2284,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/services/storage/index.ts b/src/services/storage/index.ts
    index 49b4f5b..5b8ef8a 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';
    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/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}
        ); } From 1da073f2965e3fc5f76b2969cb01ea776a515425 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:37:58 +0100 Subject: [PATCH 09/23] test(background): dispatch through every onMessage listener and fix mocks The handler suites added in #125 never passed: - background-storage-handlers / background-auth-handlers invoked only the last registered chrome.runtime.onMessage listener, assuming it was the MessageBus. The background also registers a raw scheduler-control listener after the bus, so every MessageBus message came back NOT_HANDLED. Add tests/mocks/runtimeMessages.ts, which offers the message to each listener like Chrome does, and use it in both suites. - AUTH_LOGOUT expected getOrg() to return undefined for a removed org; StorageService.getOrg is typed and implemented to return null. Assert null, and also assert the active org is cleared. - SalesforceAuth.login test mocked chrome.tabs.query callback-style, but auth.ts uses the MV3 promise form, so the mock threw "callback is not a function". Mock a resolved promise instead. No assertions were weakened; handler behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/mocks/runtimeMessages.ts | 54 +++++++++++++++++++ tests/unit/background-auth-handlers.test.ts | 26 +++------ tests/unit/background-services.test.ts | 7 +-- .../unit/background-storage-handlers.test.ts | 18 ++----- 4 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 tests/mocks/runtimeMessages.ts 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/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', () => { From bd52d742e6cada8dc519af8e246780d1298cd5b9 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:38:19 +0100 Subject: [PATCH 10/23] test(api): cover rawCall same-origin passthrough and cross-origin block PR #118 (issue #65) intentionally made rawCall reject absolute URLs whose origin differs from the org instanceUrl, so the Bearer token cannot be sent to another host. The old "uses an absolute URL verbatim" test still targeted https://other.example.com and so asserted the token-leaking behaviour the fix removed. That expectation is obsolete; replace it with a same-origin verbatim test plus a test that the cross-origin call is rejected with URL_ORIGIN_MISMATCH and no fetch is made. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/api-rawcall.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 () => { From 7e6feff259fd707a137ccbda34a2ee82151978e6 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:39:02 +0100 Subject: [PATCH 11/23] fix: check lookup match fields against the referenced object on Import (#50) DataPushScreen now describes the objects referenced by relationship lookups (cached per tab) and passes them to validation and the dry run, so a lookup matching on a non-External ID field is caught before push. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/screens/DataPushScreen.tsx | 37 ++++++++- src/ui/utils/pushDryRun.ts | 4 +- tests/unit/import-lookup-validation.test.tsx | 83 ++++++++++++++++++++ tests/unit/push-dry-run.test.ts | 24 ++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/unit/import-lookup-validation.test.tsx diff --git a/src/ui/screens/DataPushScreen.tsx b/src/ui/screens/DataPushScreen.tsx index 1dace3e..d0b8ce3 100644 --- a/src/ui/screens/DataPushScreen.tsx +++ b/src/ui/screens/DataPushScreen.tsx @@ -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; @@ -286,6 +291,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([]); @@ -439,6 +473,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'); @@ -451,7 +486,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.' }); 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/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/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); + }); +}); From 183a36febc592f08539061158c4262ce46f19a23 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:39:26 +0100 Subject: [PATCH 12/23] fix(bulk): send and read Bulk CSV values verbatim Two fix PRs made the Bulk API path corrupt field values: - PR #118 (issue #66) applied the spreadsheet formula-injection guard to the Bulk *ingest* CSV. Any value starting with - + = @ was uploaded with a leading apostrophe: -5 failed on number fields and '+44 ... phones or '@handles were stored with the apostrophe. The REST path never did this, so the same import behaved differently above the Bulk threshold. #66 is about files opened in Excel; exports keep their guard. - PR #119 (issue #80) enabled Papa dynamicTyping on Bulk query results, which coerces Text fields: "02134" -> 2134, 18-digit numbers lose precision, "true" -> true. This broke the existing bulk-query test (Id "001" -> 1). Reverted; correct typing needs field describe data. Adds tests/unit/bulk-csv-fidelity.test.ts for both directions. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/services/salesforce/bulk-api.ts | 13 +++--- tests/unit/bulk-csv-fidelity.test.ts | 68 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/unit/bulk-csv-fidelity.test.ts diff --git a/src/services/salesforce/bulk-api.ts b/src/services/salesforce/bulk-api.ts index e95ad1d..0a35b3d 100644 --- a/src/services/salesforce/bulk-api.ts +++ b/src/services/salesforce/bulk-api.ts @@ -70,12 +70,10 @@ export function bulkCsvCellValue(record: Record, header: string const val = record[header]; if (val === undefined) return ''; if (val === null) return BULK_NULL_VALUE; - const str = String(val); - // Security (#66): neutralize formula-injection payloads in upload CSV. - if (/^[=+\-@\t\r]/.test(str)) { - return `'${str}`; - } - return str; + // 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); } /** @@ -281,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). Correct typing needs field metadata, not CSV guessing. transformHeader: header => header.trim(), }); if (parsed.errors.length > 0) { 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', + }]); + }); +}); From da735311298491c1286667974cc99ea47bfb11de Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:41:32 +0100 Subject: [PATCH 13/23] fix(migration): stop stalling on all-failed pushes and test ID pairing Follow-up to 9f6f7b3, which fixed the PR #110 merge bug (the poller returned string[] while the consumer read result.ids/failedRecords, so every migrated object threw, no ID map was saved and child lookups were never remapped - Copy between orgs, #43) but kept two defects and had no test: - awaitPushResult still waited for ids.length > 0. A push where every record failed stores a result with ids: [], so the run polled for the full 5 minutes, then marked the object "done" with nothing reported. It now returns the first stored result (results are only written on completion), and a real timeout fails the object. - Per-record push failures are surfaced in the object's errors. The poller and the idRecordIndexes pairing move to ui/utils/migrationPush with unit tests. Also restores the em dashes PR #110 turned into mojibake and drops the BOM it added. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/screens/MigrationWorkspaceScreen.tsx | 65 ++++++-------- src/ui/utils/migrationPush.ts | 64 ++++++++++++++ tests/unit/migrationPush.test.ts | 95 +++++++++++++++++++++ 3 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 src/ui/utils/migrationPush.ts create mode 100644 tests/unit/migrationPush.test.ts diff --git a/src/ui/screens/MigrationWorkspaceScreen.tsx b/src/ui/screens/MigrationWorkspaceScreen.tsx index 3fbfdc3..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. * @@ -14,26 +14,10 @@ import type { JSX } from 'preact'; import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import type { SfApi } from '../api/sf'; -import type { DataPushResultGetResponse } from '../../core/types/messaging'; 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 stored result, 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; - await new Promise((r) => setTimeout(r, intervalMs)); - } - return null; -} +import { awaitPushResult, buildIdMapEntries } from '../utils/migrationPush'; interface Props { sf: SfApi; @@ -191,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)) { @@ -220,29 +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) { - // Pair each new ID with the source row that produced it; IDs can arrive out of input - // order, so they are never zipped positionally (#47). - const entries: Array<{ sourceId: string; targetId: string; objectName: string; createdAt: number }> = []; - result.ids.forEach((targetId, k) => { - const recordIndex = result.idRecordIndexes?.[k] ?? -1; - const source = recordIndex >= 0 ? queryResult.records[recordIndex] as Record | undefined : undefined; - const sourceId = source?.Id as string | undefined; - 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/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/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([]); + }); +}); From ce82e02c4739e257493163f342d2e542d3346da2 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:44:34 +0100 Subject: [PATCH 14/23] fix(storage): keep the quota guard from blocking or recursing on writes PR #114 (#56/#63/#94/#99) added a pre-write quota check to setLocal with two defects: - Infinite recursion: when usage is above 85% of quota and an old terminal push exists, setLocal -> evictForQuota -> pruneTerminalPushes -> setLocal -> evictForQuota ... re-reads the not-yet-pruned map each time and never returns, hanging every local write (push checkpoints, history, settings). Eviction now prunes via a shared helper and writes directly to chrome.storage.local, never back through setLocal. - Any failure of the usage check or eviction (e.g. getBytesInUse rejecting) turned the caller's write into a StorageError. The check is now best-effort; the write and its quota-error retry still run. The chrome mock returned stored object references from get(), so callers could mutate "storage" without set(). That masked the recursion above. get() now returns a copy, as chrome.storage does. The new tests/unit/storage-quota.test.ts hangs on the old code and passes now. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/services/storage/index.ts | 43 ++++++++++++++------- tests/mocks/chromeMock.ts | 12 +++++- tests/unit/storage-quota.test.ts | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 tests/unit/storage-quota.test.ts diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 5b8ef8a..0240298 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -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/tests/mocks/chromeMock.ts b/tests/mocks/chromeMock.ts index dd9ce32..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 = { @@ -54,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); @@ -84,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/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'); + }); +}); From 57498228ef4278b63e4112ceaebfbbc36587b2f1 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:48:30 +0100 Subject: [PATCH 15/23] chore(deps): apply non-breaking npm audit fixes to dev dependencies Lockfile-only refresh from `npm audit fix` (brace-expansion, browserslist, fast-uri, serialize-javascript and friends). package-lock.json is unchanged since the last green CI run; these advisories were published since then. Production audit stays clean. `npm run audit:dev` still fails: braces <=3.0.3 (GHSA-vfj7-8cjw-p6xm, pulled in by jest 29 via micromatch) has no patched release. Clearing it needs the jest 30 major upgrade, which is not part of this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 147 +++++++++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 845d641..d43dbfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -672,9 +672,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 +722,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": { @@ -3244,13 +3244,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,9 +3264,9 @@ "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": "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": { @@ -3284,9 +3287,9 @@ } }, "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 +3307,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 +3432,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": [ { @@ -4220,9 +4223,9 @@ } }, "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" }, @@ -4628,9 +4631,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 +4692,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": { @@ -4902,9 +4905,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": [ { @@ -5275,9 +5278,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": { @@ -7539,11 +7542,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", @@ -8114,9 +8120,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": { @@ -8666,9 +8672,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": { @@ -9357,9 +9363,9 @@ } }, "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": "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": { @@ -9564,24 +9570,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": { @@ -9824,9 +9847,9 @@ } }, "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": [ { From b9fef748f519f59bb806f10d55a83e16f96d8c86 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:50:50 +0100 Subject: [PATCH 16/23] chore: strip stray BOMs and repair mis-encoded dashes in source comments Co-Authored-By: Claude Opus 5.5 (1M context) --- src/data/mappers/index.ts | 6 +++--- src/ui/components/RelationshipConfig.tsx | 2 +- src/ui/components/RelationshipTree.tsx | 2 +- src/ui/components/SchemaDiffView.tsx | 2 +- src/ui/components/ShortcutEditor.tsx | 2 +- src/ui/components/Skeleton.tsx | 2 +- src/ui/components/StepConfigPanel.tsx | 2 +- src/ui/components/TemplateCard.tsx | 2 +- src/ui/components/TemplateEditor.tsx | 2 +- src/ui/components/UndoHistoryPanel.tsx | 2 +- src/ui/components/query-builder/SubqueryBuilder.tsx | 2 +- src/ui/components/query-builder/WhereBuilder.tsx | 2 +- src/ui/screens/JobsActivityScreen.tsx | 2 +- src/ui/screens/SavedJobsScreen.tsx | 2 +- src/ui/utils/soqlBuilder.ts | 4 ++-- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/data/mappers/index.ts b/src/data/mappers/index.ts index d69e188..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. * @@ -40,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; @@ -227,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/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/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/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/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/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'; From d0f698da8fc8c1c61ee0b5fec4eeac9fd32a84f5 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:53:02 +0100 Subject: [PATCH 17/23] chore(deps): upgrade jest to 30 and typescript-eslint to 8 to clear audit advisories Clears 37 high-severity dev advisories (braces <=3.0.3 via jest 29's micromatch, GHSA-vfj7-8cjw-p6xm) that kept the CI audit:dev step red. typescript-eslint 8 flagged one unused catch binding. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 4552 ++++++++++++++++++++++++++++-------------- package.json | 12 +- src/content/index.ts | 3 +- 3 files changed, 3084 insertions(+), 1483 deletions(-) diff --git a/package-lock.json b/package-lock.json index d43dbfb..0640efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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": { @@ -3264,26 +4449,26 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "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": { @@ -3490,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": [ { @@ -3506,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" }, @@ -3600,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", @@ -3661,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", @@ -3791,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", @@ -3826,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": { @@ -3920,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": { @@ -4020,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", @@ -4070,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", @@ -4151,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", @@ -4222,6 +5321,13 @@ "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.444", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", @@ -4429,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" }, @@ -4521,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", @@ -4827,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": { @@ -4860,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", @@ -4964,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", @@ -5042,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": { @@ -5066,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", @@ -5334,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", @@ -5509,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": { @@ -5614,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": { @@ -6069,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", @@ -6351,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" @@ -6379,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" @@ -6449,522 +7474,1007 @@ } }, "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", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "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": "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-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "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", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "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": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^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-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": { + "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": { - "detect-newline": "^3.0.0" + "@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": "^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/@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", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^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-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/@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/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" + "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" }, - "peerDependencies": { - "canvas": "^2.5.0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/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" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "engines": { + "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": { @@ -6980,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": { @@ -7063,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": { @@ -7198,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", @@ -7363,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", @@ -7390,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", @@ -7456,18 +8933,18 @@ "engines": { "node": ">=4" } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + }, + "node_modules/minimatch": { + "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" @@ -7483,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", @@ -7509,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", @@ -7534,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", @@ -7588,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" }, @@ -7798,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", @@ -7908,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": { @@ -8199,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", @@ -8237,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": [ { @@ -8253,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", @@ -8403,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", @@ -8464,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", @@ -8502,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", @@ -8899,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", @@ -8936,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", @@ -9020,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", @@ -9108,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", @@ -9204,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", @@ -9348,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.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "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": { @@ -9441,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" }, @@ -9527,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": { @@ -9836,14 +11399,42 @@ "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": { @@ -9887,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", @@ -9935,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": { @@ -10136,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", @@ -10146,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": { @@ -10320,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", @@ -10328,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": { @@ -10376,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": { @@ -10410,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..72ec7ec 100644 --- a/package.json +++ b/package.json @@ -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/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 } } From 2431f58eafd100cc9e3165c3dfa388ed30b65e25 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:56:20 +0100 Subject: [PATCH 18/23] fix(clone): pair inserted IDs with source rows by result index (#49) The clone wizard still zipped returned IDs onto source rows positionally, skipping only rows listed in failedRecords. Bulk and multi-threaded REST results arrive out of order, and Bulk failures with an unidentifiable row (-1) are absent from failedRecords, so child records were re-parented to the wrong targets. pairClonedIds now maps each ID through idRecordIndexes. IDs whose index is unknown, out of range, shared by another ID, or marked failed are left unmapped and reported as a warning in the execution log instead of being guessed. The screen also reuses migrationPush.awaitPushResult, so a push in which every record failed no longer polls until the five-minute timeout. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/screens/CloneWizardScreen.tsx | 44 ++++++---------------- src/ui/utils/crossObjectClone.ts | 56 ++++++++++++++++++++++++++++ tests/unit/crossObjectClone.test.ts | 54 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 33 deletions(-) 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/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/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); + }); +}); From 3c9d3261dc22c734421036c6f2ba50e79bb01fbd Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:56:20 +0100 Subject: [PATCH 19/23] fix(import): report rows dropped at mapping in the error file Rows excluded by mapRecords were never pushed and appeared in neither the success nor the error download. buildPushOutcomeDatasets now takes the source-indexed mapping errors and writes every unpushed source row to the error file ("Not pushed (mapping error): ...") with its WaveLinkSourceRow, in source order alongside push failures. The error-file button also shows when only mapping drops occurred. Retry Failed Rows still re-sends only push failures: mapping-dropped rows would fail mapping again with the same mappings, so the retry toast now says how many were left out and points to the error file. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/screens/DataPushScreen.tsx | 17 ++++++-- src/ui/utils/pushRetry.ts | 40 ++++++++++++++++--- tests/unit/push-results.test.ts | 64 ++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/ui/screens/DataPushScreen.tsx b/src/ui/screens/DataPushScreen.tsx index d0b8ce3..d8cd39d 100644 --- a/src/ui/screens/DataPushScreen.tsx +++ b/src/ui/screens/DataPushScreen.tsx @@ -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'; @@ -189,6 +189,8 @@ export function DataPushScreen(props: { 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); @@ -643,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' }); } @@ -659,6 +664,7 @@ export function DataPushScreen(props: { pushErrors ?? [], stored ?? undefined, lastPushConfig.sourceIndexes, + lastPushConfig.mappingErrors, ); const selected = datasets[kind]; await exportRecords(selected.records, selected.headers, { @@ -673,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; @@ -1221,7 +1230,7 @@ export function DataPushScreen(props: { <> - {push.failed > 0 ? : null} + {push.failed > 0 || droppedAtMapping > 0 ? : null} {push.failed > 0 && pushErrors && pushErrors.length > 0 ? ( @@ -1294,6 +1303,7 @@ export function DataPushScreen(props: { setLastPushConfig({ sourceRecords, sourceIndexes: mappedSourceIndexes ?? undefined, + mappingErrors: mappedSourceIndexes ? (mappingErrors ?? undefined) : undefined, mappings: [...mappings], }); setToast({ title: 'Push Started', body: `${res.strategy.toUpperCase()} - ${res.pushId}` }); @@ -1381,6 +1391,7 @@ export function DataPushScreen(props: { 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/utils/pushRetry.ts b/src/ui/utils/pushRetry.ts index ae72829..84963bb 100644 --- a/src/ui/utils/pushRetry.ts +++ b/src/ui/utils/pushRetry.ts @@ -28,19 +28,38 @@ export function toSourceRecordIndex(pushedIndex: number, sourceIndexes?: number[ 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 were never pushed and - * appear in neither file. + * 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 }>, 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(); @@ -55,12 +74,20 @@ export function buildPushOutcomeDatasets( const id = rowIds.ids[i]; if (id && inSource(sourceIndex)) idsBySource.set(sourceIndex, id); }); - const pushedSourceIndexes = sourceIndexes ?? originalRecords.map((_, i) => i); + 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]); + } const successRecords: Record[] = []; const errorRecords: Record[] = []; - for (const recordIndex of pushedSourceIndexes) { - if (!inSource(recordIndex)) continue; + 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 }); @@ -85,6 +112,9 @@ 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 pushed-record indices and messages * @param sourceIndexes - DataMapper's mapped -> source index map (#46); omit when rows were pushed 1:1 diff --git a/tests/unit/push-results.test.ts b/tests/unit/push-results.test.ts index 0063bf4..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, buildRetryDataset } 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 () => { @@ -93,8 +93,10 @@ describe('source-row translation after mapping drops rows (#46)', () => { 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 }, ]); @@ -106,3 +108,63 @@ describe('source-row translation after mapping drops rows (#46)', () => { }); }); +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); + }); +}); From fac9c1e0cc367557fc0570c0f519a21e2edfe694 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:56:47 +0100 Subject: [PATCH 20/23] fix(export): guard only string cells against CSV formula injection The CSV exporter stringified every value before applying the #66 formula-injection guard, so genuine negative numbers exported as the text '-5. Guard string cells only, matching the Excel exporter; numbers and booleans pass through unchanged while string cells starting with = + - @ tab or CR are still neutralised. The Bulk upload CSV remains unguarded (183a36f). Refs #66 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/utils/csv.ts | 9 ++++--- tests/unit/csv-formula-guard.test.ts | 40 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 tests/unit/csv-formula-guard.test.ts 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/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(''); + }); +}); From b2c8e8328b20eb504f0584813ae6439a9ecc4ba6 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:56:47 +0100 Subject: [PATCH 21/23] fix(bulk): type Bulk query results from field describe metadata Bulk API 2.0 query results are CSV, so every value reached exports as a string while REST returned typed JSON (#80). PR #119 guessed types from the CSV text and corrupted Text fields; 183a36f reverted it. Type each page in the background from the queried object's describe (via the schema cache): boolean -> boolean; int/double/currency/percent -> number only when exactly representable (otherwise kept as string); empty cell -> null like REST; dates, datetimes, Ids and text stay strings. Relationship columns such as Account.Owner.Name resolve via each lookup's referenceTo; polymorphic or unresolvable columns stay strings. The queried object comes from the job info `object` field, memoised per job. Describe or status failures degrade to strings. Also extracts describeSObjectCached, reused by SF_DESCRIBE_SOBJECT. Refs #80 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/background/index.ts | 40 +++-- src/core/types/salesforce.ts | 2 + src/services/salesforce/bulk-api.ts | 2 +- src/services/salesforce/bulk-query-typing.ts | 137 +++++++++++++++ tests/unit/bulk-query-typing.test.ts | 165 +++++++++++++++++++ 5 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 src/services/salesforce/bulk-query-typing.ts create mode 100644 tests/unit/bulk-query-typing.test.ts diff --git a/src/background/index.ts b/src/background/index.ts index 94ade77..49dc398 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -24,6 +24,7 @@ import { SalesforceAuth } from '../services/salesforce/auth'; 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 123, long numbers lose precision, - // "true" -> boolean). Correct typing needs field metadata, not CSV guessing. + // "true" -> boolean). Values are typed from field describe metadata instead (bulk-query-typing.ts). transformHeader: header => header.trim(), }); if (parsed.errors.length > 0) { 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/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' }]); + }); +}); From 35c1bb16774129843c7a91c5e9e69cf4e6cb7bf6 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:00:44 +0100 Subject: [PATCH 22/23] chore(release): prepare v0.7.0 Bump to 0.7.0, add the changelog, apply the new store name and summary, align store and privacy permission declarations with the manifest (unlimitedStorage added, activeTab removed), record the nebula-dev validation run, and extend validate:salesforce with v0.7.0 checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 37 +++++ PRIVACY.md | 4 +- docs/CHROME_WEB_STORE.md | 96 +++++++----- docs/launch-kit.md | 241 +++++++++++++++++++++++++++++ docs/release-validation.md | 12 ++ package-lock.json | 4 +- package.json | 2 +- public/manifest.json | 6 +- roadmap.md | 13 +- scripts/validate-salesforce-org.ts | 73 +++++++++ 10 files changed, 435 insertions(+), 53 deletions(-) create mode 100644 docs/launch-kit.md 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..31d986a 100644 --- a/docs/release-validation.md +++ b/docs/release-validation.md @@ -99,6 +99,18 @@ 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`). +- Not yet run on 0.7.0: the packaged-extension browser matrix above. + ## Maintainer-authorized usability gate Run the packaged build without using implementation notes: diff --git a/package-lock.json b/package-lock.json index 0640efa..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", diff --git a/package.json b/package.json index 72ec7ec..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": { 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..c28dc6b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,11 +1,20 @@ # 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`). Remaining before 1.0: the +> packaged-extension browser matrix on 0.7.0 and 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); From 1ffd000fab9d767bcf508196c4a07bfd28247677 Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:06:27 +0100 Subject: [PATCH 23/23] docs: record the maintainer's packaged-extension check for v0.7.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/release-validation.md | 3 ++- roadmap.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release-validation.md b/docs/release-validation.md index 31d986a..990e2f0 100644 --- a/docs/release-validation.md +++ b/docs/release-validation.md @@ -109,7 +109,8 @@ packaged-extension browser scenarios above. 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`). -- Not yet run on 0.7.0: the packaged-extension browser matrix above. +- 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 diff --git a/roadmap.md b/roadmap.md index c28dc6b..60756a4 100644 --- a/roadmap.md +++ b/roadmap.md @@ -12,8 +12,9 @@ > 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`). Remaining before 1.0: the -> packaged-extension browser matrix on 0.7.0 and post-release field feedback. +> `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