diff --git a/package.json b/package.json index a555fe00..bb716f71 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,11 @@ "devDependencies": { "@excalidraw/eslint-config": "1.0.0", "@excalidraw/prettier-config": "1.0.2", + "@types/chai": "4.2.22", "@types/lodash.throttle": "4.1.6", "@types/pako": "1.0.1", "@types/resize-observer-browser": "0.1.5", + "chai": "4.3.4", "eslint-config-prettier": "8.3.0", "eslint-plugin-prettier": "3.3.1", "firebase-tools": "9.9.0", diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx index e560bffc..e334be2b 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/src/excalidraw-app/collab/CollabWrapper.tsx @@ -8,10 +8,7 @@ import { ExcalidrawElement, InitializedExcalidrawImageElement, } from "../../element/types"; -import { - getElementMap, - getSceneVersion, -} from "../../packages/excalidraw/index"; +import { getSceneVersion } from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../types"; import { preventUnload, @@ -64,6 +61,10 @@ import { isInitializedImageElement, } from "../../element/typeChecks"; import { mutateElement } from "../../element/mutateElement"; +import { + ReconciledElements, + reconcileElements as _reconcileElements, +} from "./reconciliation"; interface CollabState { modalIsShown: boolean; @@ -87,10 +88,6 @@ export interface CollabAPI { fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; } -type ReconciledElements = readonly ExcalidrawElement[] & { - _brand: "reconciledElements"; -}; - interface Props { excalidrawAPI: ExcalidrawImperativeAPI; onRoomClose?: () => void; @@ -227,7 +224,7 @@ class CollabWrapper extends PureComponent { }); saveCollabRoomToFirebase = async ( - syncableElements: ExcalidrawElement[] = this.getSyncableElements( + syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements( this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), ) => { @@ -484,65 +481,26 @@ class CollabWrapper extends PureComponent { }; private reconcileElements = ( - elements: readonly ExcalidrawElement[], + remoteElements: readonly ExcalidrawElement[], ): ReconciledElements => { - const currentElements = this.getSceneElementsIncludingDeleted(); - // create a map of ids so we don't have to iterate - // over the array more than once. - const localElementMap = getElementMap(currentElements); - + const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); - // Reconcile - const newElements: readonly ExcalidrawElement[] = elements - .reduce((elements, element) => { - // if the remote element references one that's currently - // edited on local, skip it (it'll be added in the next step) - if ( - element.id === appState.editingElement?.id || - element.id === appState.resizingElement?.id || - element.id === appState.draggingElement?.id - ) { - return elements; - } - - if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version > element.version - ) { - elements.push(localElementMap[element.id]); - delete localElementMap[element.id]; - } else if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version === element.version && - localElementMap[element.id].versionNonce !== element.versionNonce - ) { - // resolve conflicting edits deterministically by taking the one with the lowest versionNonce - if (localElementMap[element.id].versionNonce < element.versionNonce) { - elements.push(localElementMap[element.id]); - } else { - // it should be highly unlikely that the two versionNonces are the same. if we are - // really worried about this, we can replace the versionNonce with the socket id. - elements.push(element); - } - delete localElementMap[element.id]; - } else { - elements.push(element); - delete localElementMap[element.id]; - } - - return elements; - }, [] as Mutable) - // add local elements that weren't deleted or on remote - .concat(...Object.values(localElementMap)); + const reconciledElements = _reconcileElements( + localElements, + remoteElements, + appState, + ); // Avoid broadcasting to the rest of the collaborators the scene // we just received! // Note: this needs to be set before updating the scene as it // synchronously calls render. - this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); + this.setLastBroadcastedOrReceivedSceneVersion( + getSceneVersion(reconciledElements), + ); - return newElements as ReconciledElements; + return reconciledElements; }; private loadImageFiles = throttle(async () => { @@ -681,11 +639,7 @@ class CollabWrapper extends PureComponent { getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { - this.portal.broadcastScene( - SCENE.UPDATE, - this.getSyncableElements(elements), - false, - ); + this.portal.broadcastScene(SCENE.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } @@ -694,9 +648,7 @@ class CollabWrapper extends PureComponent { queueBroadcastAllElements = throttle(() => { this.portal.broadcastScene( SCENE.UPDATE, - this.getSyncableElements( - this.excalidrawAPI.getSceneElementsIncludingDeleted(), - ), + this.excalidrawAPI.getSceneElementsIncludingDeleted(), true, ); const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion(); @@ -722,8 +674,12 @@ class CollabWrapper extends PureComponent { }); }; + isSyncableElement = (element: ExcalidrawElement) => { + return element.isDeleted || !isInvisiblySmallElement(element); + }; + getSyncableElements = (elements: readonly ExcalidrawElement[]) => - elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el)); + elements.filter((element) => this.isSyncableElement(element)); /** PRIVATE. Use `this.getContextValue()` instead. */ private contextValue: CollabAPI | null = null; diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index 126cefc7..4f1d6c6a 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/src/excalidraw-app/collab/Portal.tsx @@ -12,6 +12,7 @@ import { UserIdleState } from "../../types"; import { trackEvent } from "../../analytics"; import { throttle } from "lodash"; import { mutateElement } from "../../element/mutateElement"; +import { BroadcastedExcalidrawElement } from "./reconciliation"; class Portal { collab: CollabWrapper; @@ -40,9 +41,7 @@ class Portal { this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene( SCENE.INIT, - this.collab.getSyncableElements( - this.collab.getSceneElementsIncludingDeleted(), - ), + this.collab.getSceneElementsIncludingDeleted(), /* syncAll */ true, ); }); @@ -124,24 +123,35 @@ class Portal { broadcastScene = async ( sceneType: SCENE.INIT | SCENE.UPDATE, - syncableElements: ExcalidrawElement[], + allElements: readonly ExcalidrawElement[], syncAll: boolean, ) => { if (sceneType === SCENE.INIT && !syncAll) { throw new Error("syncAll must be true when sending SCENE.INIT"); } - if (!syncAll) { - // sync out only the elements we think we need to to save bandwidth. - // periodically we'll resync the whole thing to make sure no one diverges - // due to a dropped message (server goes down etc). - syncableElements = syncableElements.filter( - (syncableElement) => - !this.broadcastedElementVersions.has(syncableElement.id) || - syncableElement.version > - this.broadcastedElementVersions.get(syncableElement.id)!, - ); - } + // sync out only the elements we think we need to to save bandwidth. + // periodically we'll resync the whole thing to make sure no one diverges + // due to a dropped message (server goes down etc). + const syncableElements = allElements.reduce( + (acc, element: BroadcastedExcalidrawElement, idx, elements) => { + if ( + (syncAll || + !this.broadcastedElementVersions.has(element.id) || + element.version > + this.broadcastedElementVersions.get(element.id)!) && + this.collab.isSyncableElement(element) + ) { + acc.push({ + ...element, + // z-index info for the reconciler + parent: idx === 0 ? "^" : elements[idx - 1]?.id, + }); + } + return acc; + }, + [] as BroadcastedExcalidrawElement[], + ); const data: SocketUpdateDataSource[typeof sceneType] = { type: sceneType, diff --git a/src/excalidraw-app/collab/reconciliation.ts b/src/excalidraw-app/collab/reconciliation.ts new file mode 100644 index 00000000..6a0aee0a --- /dev/null +++ b/src/excalidraw-app/collab/reconciliation.ts @@ -0,0 +1,162 @@ +import { ExcalidrawElement } from "../../element/types"; +import { AppState } from "../../types"; + +export type ReconciledElements = readonly ExcalidrawElement[] & { + _brand: "reconciledElements"; +}; + +export type BroadcastedExcalidrawElement = ExcalidrawElement & { + parent?: string; +}; + +const shouldDiscardRemoteElement = ( + localAppState: AppState, + local: ExcalidrawElement | undefined, + remote: BroadcastedExcalidrawElement, +): boolean => { + if ( + local && + // local element is being edited + (local.id === localAppState.editingElement?.id || + local.id === localAppState.resizingElement?.id || + local.id === localAppState.draggingElement?.id || + // local element is newer + local.version > remote.version || + // resolve conflicting edits deterministically by taking the one with + // the lowest versionNonce + (local.version === remote.version && + local.versionNonce < remote.versionNonce)) + ) { + return true; + } + return false; +}; + +const getElementsMapWithIndex = ( + elements: readonly T[], +) => + elements.reduce( + ( + acc: { + [key: string]: [element: T, index: number] | undefined; + }, + element: T, + idx, + ) => { + acc[element.id] = [element, idx]; + return acc; + }, + {}, + ); + +export const reconcileElements = ( + localElements: readonly ExcalidrawElement[], + remoteElements: readonly BroadcastedExcalidrawElement[], + localAppState: AppState, +): ReconciledElements => { + const localElementsData = getElementsMapWithIndex( + localElements, + ); + + const reconciledElements: ExcalidrawElement[] = localElements.slice(); + + const duplicates = new WeakMap(); + + let cursor = 0; + let offset = 0; + + let remoteElementIdx = -1; + for (const remoteElement of remoteElements) { + remoteElementIdx++; + + const local = localElementsData[remoteElement.id]; + + if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { + if (remoteElement.parent) { + delete remoteElement.parent; + } + + continue; + } + + if (local) { + // mark for removal since it'll be replaced with the remote element + duplicates.set(local[0], true); + } + + // parent may not be defined in case the remote client is running an older + // excalidraw version + const parent = + remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null; + + if (parent != null) { + delete remoteElement.parent; + + // ^ indicates the element is the first in elements array + if (parent === "^") { + offset++; + if (cursor === 0) { + reconciledElements.unshift(remoteElement); + localElementsData[remoteElement.id] = [ + remoteElement, + cursor - offset, + ]; + } else { + reconciledElements.splice(cursor + 1, 0, remoteElement); + localElementsData[remoteElement.id] = [ + remoteElement, + cursor + 1 - offset, + ]; + cursor++; + } + } else { + let idx = localElementsData[parent] + ? localElementsData[parent]![1] + : null; + if (idx != null) { + idx += offset; + } + if (idx != null && idx >= cursor) { + reconciledElements.splice(idx + 1, 0, remoteElement); + offset++; + localElementsData[remoteElement.id] = [ + remoteElement, + idx + 1 - offset, + ]; + cursor = idx + 1; + } else if (idx != null) { + reconciledElements.splice(cursor + 1, 0, remoteElement); + offset++; + localElementsData[remoteElement.id] = [ + remoteElement, + cursor + 1 - offset, + ]; + cursor++; + } else { + reconciledElements.push(remoteElement); + localElementsData[remoteElement.id] = [ + remoteElement, + reconciledElements.length - 1 - offset, + ]; + } + } + // no parent z-index information, local element exists → replace in place + } else if (local) { + reconciledElements[local[1]] = remoteElement; + localElementsData[remoteElement.id] = [remoteElement, local[1]]; + // otherwise push to the end + } else { + reconciledElements.push(remoteElement); + localElementsData[remoteElement.id] = [ + remoteElement, + reconciledElements.length - 1 - offset, + ]; + } + } + + const ret: readonly ExcalidrawElement[] = reconciledElements.filter( + (element) => !duplicates.has(element), + ); + + return ret as ReconciledElements; +}; diff --git a/src/tests/reconciliation.test.ts b/src/tests/reconciliation.test.ts new file mode 100644 index 00000000..0f5399f0 --- /dev/null +++ b/src/tests/reconciliation.test.ts @@ -0,0 +1,304 @@ +import { expect } from "chai"; +import { ExcalidrawElement } from "../element/types"; +import { + BroadcastedExcalidrawElement, + ReconciledElements, + reconcileElements, +} from "../excalidraw-app/collab/reconciliation"; +import { randomInteger } from "../random"; +import { AppState } from "../types"; + +type Id = string; +type Ids = Id[]; + +type Cache = Record; + +const parseId = (uid: string) => { + const [, parent, id, version] = uid.match( + /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/, + )!; + return { + uid: version ? `${id}:${version}` : id, + id, + version: version ? parseInt(version) : null, + parent: parent || null, + }; +}; + +const idsToElements = ( + ids: Ids, + cache: Cache = {}, +): readonly ExcalidrawElement[] => { + return ids.reduce((acc, _uid, idx) => { + const { uid, id, version, parent } = parseId(_uid); + const cached = cache[uid]; + const elem = { + id, + version: version ?? 0, + versionNonce: randomInteger(), + ...cached, + parent, + } as BroadcastedExcalidrawElement; + cache[uid] = elem; + acc.push(elem); + return acc; + }, [] as ExcalidrawElement[]); +}; + +const addParents = (elements: BroadcastedExcalidrawElement[]) => { + return elements.map((el, idx, els) => { + el.parent = els[idx - 1]?.id || "^"; + return el; + }); +}; + +const cleanElements = (elements: ReconciledElements) => { + return elements.map((el) => { + // @ts-ignore + delete el.parent; + // @ts-ignore + delete el.next; + // @ts-ignore + delete el.prev; + return el; + }); +}; + +const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data)); + +const test = ( + local: Ids, + remote: Ids, + target: U[], + bidirectional = true, +) => { + const cache: Cache = {}; + const _local = idsToElements(local, cache); + const _remote = idsToElements(remote, cache); + const _target = (target.map((uid) => { + const [, id, source] = uid.match(/^(\w+):([LR])$/)!; + return (source === "L" ? _local : _remote).find((e) => e.id === id)!; + }) as any) as ReconciledElements; + const remoteReconciled = reconcileElements(_local, _remote, {} as AppState); + expect(cleanElements(remoteReconciled)).deep.equal( + cleanElements(_target), + "remote reconciliation", + ); + + const __local = cleanElements(cloneDeep(_remote)); + const __remote = addParents(cleanElements(cloneDeep(remoteReconciled))); + if (bidirectional) { + try { + expect( + cleanElements( + reconcileElements( + cloneDeep(__local), + cloneDeep(__remote), + {} as AppState, + ), + ), + ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation"); + } catch (error) { + console.error("local original", __local); + console.error("remote reconciled", __remote); + throw error; + } + } +}; + +export const findIndex = ( + array: readonly T[], + cb: (element: T, index: number, array: readonly T[]) => boolean, + fromIndex: number = 0, +) => { + if (fromIndex < 0) { + fromIndex = array.length + fromIndex; + } + fromIndex = Math.min(array.length, Math.max(fromIndex, 0)); + let index = fromIndex - 1; + while (++index < array.length) { + if (cb(array[index], index, array)) { + return index; + } + } + return -1; +}; + +// ----------------------------------------------------------------------------- + +describe("elements reconciliation", () => { + it("reconcileElements()", () => { + // ------------------------------------------------------------------------- + // + // in following tests, we pass: + // (1) an array of local elements and their version (:1, :2...) + // (2) an array of remote elements and their version (:1, :2...) + // (3) expected reconciled elements + // + // in the reconciled array: + // :L means local element was resolved + // :R means remote element was resolved + // + // if a remote element is prefixed with parentheses, the enclosed string: + // (^) means the element is the first element in the array + // () means the element is preceded by element + // + // if versions are missing, it defaults to version 0 + // ------------------------------------------------------------------------- + + // non-annotated elements + // ------------------------------------------------------------------------- + // usually when we sync elements they should always be annonated with + // their (preceding elements) parents, but let's test a couple of cases when + // they're not for whatever reason (remote clients are on older version...), + // in which case the first synced element either replaces existing element + // or is pushed at the end of the array + + test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]); + test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]); + test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]); + test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]); + test(["A", "B"], ["A:1"], ["A:R", "B:L"]); + test(["A"], ["A", "B"], ["A:L", "B:R"]); + test(["A"], ["A:1", "B"], ["A:R", "B:R"]); + test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]); + test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]); + test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]); + test(["A"], ["A:1"], ["A:R"]); + + // C isn't added to the end because it follows B (even if B was resolved + // to local version) + test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]); + + // some of the following tests are kinda arbitrary and they're less + // likely to happen in real-world cases + + test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]); + test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]); + test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]); + test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); + test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); + test( + ["A:2", "B:2", "C"], + ["D", "B:1", "A:3"], + ["B:L", "A:R", "C:L", "D:R"], + ); + test( + ["A:2", "B:2", "C"], + ["D", "B:2", "A:3", "C"], + ["D:R", "B:L", "A:R", "C:L"], + ); + test( + ["A", "B", "C", "D", "E", "F"], + ["A", "B:2", "X", "E:2", "F", "Y"], + ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"], + ); + + // annotated elements + // ------------------------------------------------------------------------- + + test( + ["A", "B", "C"], + ["(B)X", "(A)Y", "(Y)Z"], + ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"], + ); + + test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]); + test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]); + + test( + ["A", "B"], + ["(A)C", "(^)D", "F"], + ["A:L", "C:R", "D:R", "F:R", "B:L"], + ); + + test( + ["A", "B", "C", "D"], + ["(B)C:1", "B", "D:1"], + ["A:L", "C:R", "B:L", "D:R"], + ); + + test( + ["A", "B", "C"], + ["(^)X", "(A)Y", "(B)Z"], + ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"], + ); + + test( + ["B", "A", "C"], + ["(^)X", "(A)Y", "(B)Z"], + ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"], + ); + + test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]); + + test( + ["A", "B", "C", "D", "E"], + ["(A)X", "(C)Y", "(D)Z"], + ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"], + ); + + test( + ["X", "Y", "Z"], + ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"], + ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"], + ); + + test( + ["A", "B", "C", "D", "E"], + ["(C)X", "(A)Y", "(D)E:1"], + ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"], + ); + + test( + ["C:1", "B", "D:1"], + ["A", "B", "C:1", "D:1"], + ["A:R", "B:L", "C:L", "D:L"], + ); + + test( + ["A", "B", "C", "D"], + ["(A)C:1", "(C)B", "(B)D:1"], + ["A:L", "C:R", "B:L", "D:R"], + ); + + test( + ["A", "B", "C", "D"], + ["(A)C:1", "(C)B", "(B)D:1"], + ["A:L", "C:R", "B:L", "D:R"], + ); + + test( + ["C:1", "B", "D:1"], + ["(^)A", "(A)B", "(B)C:2", "(C)D:1"], + ["A:R", "B:L", "C:R", "D:L"], + ); + + test( + ["A", "B", "C", "D"], + ["(C)X", "(B)Y", "(A)Z"], + ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"], + ); + + test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]); + test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]); + test( + ["A", "B", "C", "D"], + ["(A)C:1", "B", "D:1"], + ["A:L", "C:R", "B:L", "D:R"], + ); + + test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]); + test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]); + + test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]); + test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]); + test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]); + test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]); + test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]); + test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]); + test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]); + test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); + test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 661298fe..21d06bfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2207,6 +2207,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/chai@4.2.22": + version "4.2.22" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7" + integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ== + "@types/duplexify@^3.6.0": version "3.6.0" resolved "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.0.tgz" @@ -3160,6 +3165,11 @@ assert@^1.1.1: object-assign "^4.1.1" util "0.10.3" +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz" @@ -4051,6 +4061,18 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chai@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49" + integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.1" + type-detect "^4.0.5" + chainsaw@~0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz" @@ -4104,6 +4126,11 @@ chardet@^0.7.0: resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + check-types@^11.1.1: version "11.1.2" resolved "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz" @@ -5128,6 +5155,13 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + deep-equal@^1.0.1: version "1.1.1" resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz" @@ -6902,6 +6936,11 @@ get-caller-file@^2.0.1: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz" @@ -10790,6 +10829,11 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + pbkdf2@^3.0.3: version "3.1.1" resolved "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz" @@ -14154,7 +14198,7 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@4.0.8: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==