Add a new project for recording and playing back editing commands in editable web...
authorwenson_hsieh@apple.com <wenson_hsieh@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 7 Dec 2016 20:40:43 +0000 (20:40 +0000)
committerwenson_hsieh@apple.com <wenson_hsieh@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 7 Dec 2016 20:40:43 +0000 (20:40 +0000)
https://bugs.webkit.org/show_bug.cgi?id=165114
<rdar://problem/29408135>

Reviewed by Beth Dakin.

Source/WebCore:

Adds new scripts used to record and play back editing, as well as a new Xcode Copy files phase that pushes these
scripts to the internal system directory when installing. See the Tools ChangeLog and individual comments below
for more details. Covered by 3 new unit tests in the EditingHistory project.

* InternalScripts/DumpEditingHistory.js: Added.
(beginProcessingTopLevelUpdate):
(endProcessingTopLevelUpdate):
(appendDOMUpdatesFromRecords):
(appendSelectionUpdateIfNecessary):

Adds new entries into the top-level list of DOM updates captured when editing. Respectively, these are input
events and selection changes.

(EditingHistory.getEditingHistoryAsJSONString):
* InternalScripts/EditingHistoryUtil.js: Added.
(prototype._scramble):
(prototype.applyToText):
(prototype.applyToFilename):
(prototype._scrambedNumberIndexForCode):
(prototype._scrambedLowercaseIndexForCode):
(prototype._scrambedUppercaseIndexForCode):

Naive implementation of an obfuscator. Currently, this only affects alphanumeric characters. Obfuscation is off
by default, but can be toggled on in JavaScript.

(elementFromMarkdown):
(GlobalNodeMap):
(GlobalNodeMap.prototype.nodesForGUIDs):
(GlobalNodeMap.prototype.guidsForTNodes):
(GlobalNodeMap.prototype.nodeForGUID):
(GlobalNodeMap.prototype.guidForNode):
(GlobalNodeMap.prototype.hasGUIDForNode):
(GlobalNodeMap.prototype.nodes):
(GlobalNodeMap.prototype.toObject):
(GlobalNodeMap.fromObject):
(GlobalNodeMap.dataForNode):
(GlobalNodeMap.elementFromTagName):
(GlobalNodeMap.nodeAttributesToObject):
(GlobalNodeMap.prototype.descriptionHTMLForGUID):
(GlobalNodeMap.prototype.descriptionHTMLForNode):

The GlobalNodeMap keeps track of every node that has appeared in the DOM, assigning each node a globally unique
identifier (GUID). This GUID is used when reconstructing the DOM, as well as unapplying or applying editing.

(SelectionState):
(SelectionState.prototype.isEqual):
(SelectionState.prototype.applyToSelection):
(SelectionState.fromSelection):
(SelectionState.prototype.toObject):
(SelectionState.fromObject):

Represents a snapshot of the Selection state (determined by getSelection()).

(DOMUpdate):
(DOMUpdate.prototype.apply):
(DOMUpdate.prototype.unapply):
(DOMUpdate.prototype.targetNode):
(DOMUpdate.prototype.detailsElement):
(DOMUpdate.ofType):
(DOMUpdate.fromRecords):

A DOMUpdate is an abstract object representing a change in the DOM that may be applied and unapplied. These are
also serializable as hashes, which may then be converted to JSON when generating editing history data.

(ChildListUpdate):
(ChildListUpdate.prototype.apply):
(ChildListUpdate.prototype.unapply):
(ChildListUpdate.prototype._nextSibling):
(ChildListUpdate.prototype._removedNodes):
(ChildListUpdate.prototype._addedNodes):
(ChildListUpdate.prototype.toObject):
(ChildListUpdate.prototype.detailsElement):
(ChildListUpdate.fromObject):

These three update types correspond to the three types of DOM mutations. These may appear as top-level updates
if they are not captured during an input event, but for the majority of user-input-driven changes, they will be
children of an input event.

(CharacterDataUpdate):
(CharacterDataUpdate.prototype.apply):
(CharacterDataUpdate.prototype.unapply):
(CharacterDataUpdate.prototype.detailsElement):
(CharacterDataUpdate.prototype.toObject):
(CharacterDataUpdate.fromObject):
(AttributeUpdate):
(AttributeUpdate.prototype.apply):
(AttributeUpdate.prototype.unapply):
(AttributeUpdate.prototype.detailsElement):
(AttributeUpdate.prototype.toObject):
(AttributeUpdate.fromObject):
(SelectionUpdate):
(SelectionUpdate.prototype.apply):
(SelectionUpdate.prototype.unapply):
(SelectionUpdate.prototype.toObject):
(SelectionUpdate.fromObject):
(SelectionUpdate.prototype._rangeDescriptionHTML):
(SelectionUpdate.prototype._anchorDescriptionHTML):
(SelectionUpdate.prototype._focusDescriptionHTML):
(SelectionUpdate.prototype.detailsElement):

Represents a change in the Selection. While no changes to the DOM structure occur as a result of a
SelectionUpdate, the information contained in these updates is used to determine where the selection should be
when rewinding or playing back the editing history.

(InputEventUpdate):
(InputEventUpdate.prototype._obfuscatedData):
(InputEventUpdate.prototype.apply):
(InputEventUpdate.prototype.unapply):
(InputEventUpdate.prototype.toObject):
(InputEventUpdate.fromObject):
(InputEventUpdate.prototype.detailsElement):

Represents an update due to user input, which consists of some number of child DOM mutation updates.

* WebCore.xcodeproj/project.pbxproj:

Tools:

Adds a new Xcode project containing work towards rewinding and playing back editing commands. This work is
wrapped in an Xcode project to take advantage of the XCTest framework. To manually test recording, open the
capture test harness, edit the contenteditable body, and then hit cmd-S. This downloads a .json file which may
then be dragged into the playback test harness.

Also adds 3 new unit tests in EditingHistoryTests/RewindAndPlaybackTests.m. These tests carry out the following
steps:

1. Load the capture harness and perform test-specific editing on the web view.
2. Let originalState be a dump of the DOM at this point in time.
3. Extract the JSON-serialized editing history data and load the playback harness with this data.
4. Rewind all editing to the beginning.
5. Playback all editing to the end.
6. Dump the state of the DOM. This should be identical to originalState.

* EditingHistory/EditingHistory.xcodeproj/project.pbxproj: Added.
* EditingHistory/EditingHistory/Info.plist: Added.
* EditingHistory/EditingHistory/Resources/CaptureHarness.html: Added.
* EditingHistory/EditingHistory/Resources/DOMTestingUtil.js: Added.
* EditingHistory/EditingHistory/Resources/PlaybackHarness.html: Added.
* EditingHistory/EditingHistory/TestRunner.h: Added.
* EditingHistory/EditingHistory/TestRunner.m: Added.
(injectedMessageEventHandlerScript):
(-[TestRunner init]):
(-[TestRunner deleteBackwards:]):
(-[TestRunner typeString:]):
(-[TestRunner bodyElementSubtree]):
(-[TestRunner bodyTextContent]):
(-[TestRunner editingHistoryJSON]):
(-[TestRunner loadPlaybackTestHarnessWithJSON:]):
(-[TestRunner numberOfUpdates]):
(-[TestRunner jumpToUpdateIndex:]):
(-[TestRunner expectEvents:afterPerforming:]):
(-[TestRunner loadCaptureTestHarness]):
(-[TestRunner setTextObfuscationEnabled:]):
(-[TestRunner isDoneWaitingForPendingEvents]):
(-[TestRunner userContentController:didReceiveScriptMessage:]):

The TestRunner provides utilities that a unit test should use to drive the test forward (e.g. loading harnesses)
or inspect the state of the loaded page (e.g. extracting JSON editing history data from the capture harness).

* EditingHistory/EditingHistory/TestUtil.h: Added.
* EditingHistory/EditingHistory/TestUtil.m: Added.
(waitUntilWithTimeout):
(waitUntil):

Provides utilities for running tests. For now, this is just spinning the runloop on a given condition.

* EditingHistory/EditingHistory/WKWebViewAdditions.h: Added.
* EditingHistory/EditingHistory/WKWebViewAdditions.m: Added.
(-[WKWebView loadPageFromBundleNamed:]):
(-[WKWebView typeCharacter:]):
(-[WKWebView keyPressWithCharacters:keyCode:]):
(-[WKWebView stringByEvaluatingJavaScriptFromString:]):

Provides utilities for simulating interaction in a web view.

* EditingHistory/EditingHistory/main.m: Added.
(main):
* EditingHistory/EditingHistoryTests/Info.plist: Added.
* EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m: Added.
(-[RewindAndPlaybackTests setUp]):
(-[RewindAndPlaybackTests tearDown]):
(-[RewindAndPlaybackTests testTypingSingleLineOfText]):
(-[RewindAndPlaybackTests testTypingMultipleLinesOfText]):
(-[RewindAndPlaybackTests testTypingAndDeletingText]):
(-[RewindAndPlaybackTests rewindAndPlaybackEditingInPlaybackTestHarness]):
(-[RewindAndPlaybackTests originalBodySubtree:isEqualToFinalSubtree:]):

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@209470 268f45cc-cd09-0410-ab3c-d52691b4dbfc

19 files changed:
Source/WebCore/ChangeLog
Source/WebCore/InternalScripts/DumpEditingHistory.js [new file with mode: 0644]
Source/WebCore/InternalScripts/EditingHistoryUtil.js [new file with mode: 0644]
Source/WebCore/WebCore.xcodeproj/project.pbxproj
Tools/ChangeLog
Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/Info.plist [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/TestRunner.h [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/TestRunner.m [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/TestUtil.h [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/TestUtil.m [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m [new file with mode: 0644]
Tools/EditingHistory/EditingHistory/main.m [new file with mode: 0644]
Tools/EditingHistory/EditingHistoryTests/Info.plist [new file with mode: 0644]
Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m [new file with mode: 0644]

index eda68b1..3549acc 100644 (file)
@@ -1,3 +1,127 @@
+2016-12-07  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        Add a new project for recording and playing back editing commands in editable web content
+        https://bugs.webkit.org/show_bug.cgi?id=165114
+        <rdar://problem/29408135>
+
+        Reviewed by Beth Dakin.
+
+        Adds new scripts used to record and play back editing, as well as a new Xcode Copy files phase that pushes these
+        scripts to the internal system directory when installing. See the Tools ChangeLog and individual comments below
+        for more details. Covered by 3 new unit tests in the EditingHistory project.
+
+        * InternalScripts/DumpEditingHistory.js: Added.
+        (beginProcessingTopLevelUpdate):
+        (endProcessingTopLevelUpdate):
+        (appendDOMUpdatesFromRecords):
+        (appendSelectionUpdateIfNecessary):
+
+        Adds new entries into the top-level list of DOM updates captured when editing. Respectively, these are input
+        events and selection changes.
+
+        (EditingHistory.getEditingHistoryAsJSONString):
+        * InternalScripts/EditingHistoryUtil.js: Added.
+        (prototype._scramble):
+        (prototype.applyToText):
+        (prototype.applyToFilename):
+        (prototype._scrambedNumberIndexForCode):
+        (prototype._scrambedLowercaseIndexForCode):
+        (prototype._scrambedUppercaseIndexForCode):
+
+        Naive implementation of an obfuscator. Currently, this only affects alphanumeric characters. Obfuscation is off
+        by default, but can be toggled on in JavaScript.
+
+        (elementFromMarkdown):
+        (GlobalNodeMap):
+        (GlobalNodeMap.prototype.nodesForGUIDs):
+        (GlobalNodeMap.prototype.guidsForTNodes):
+        (GlobalNodeMap.prototype.nodeForGUID):
+        (GlobalNodeMap.prototype.guidForNode):
+        (GlobalNodeMap.prototype.hasGUIDForNode):
+        (GlobalNodeMap.prototype.nodes):
+        (GlobalNodeMap.prototype.toObject):
+        (GlobalNodeMap.fromObject):
+        (GlobalNodeMap.dataForNode):
+        (GlobalNodeMap.elementFromTagName):
+        (GlobalNodeMap.nodeAttributesToObject):
+        (GlobalNodeMap.prototype.descriptionHTMLForGUID):
+        (GlobalNodeMap.prototype.descriptionHTMLForNode):
+
+        The GlobalNodeMap keeps track of every node that has appeared in the DOM, assigning each node a globally unique
+        identifier (GUID). This GUID is used when reconstructing the DOM, as well as unapplying or applying editing.
+
+        (SelectionState):
+        (SelectionState.prototype.isEqual):
+        (SelectionState.prototype.applyToSelection):
+        (SelectionState.fromSelection):
+        (SelectionState.prototype.toObject):
+        (SelectionState.fromObject):
+
+        Represents a snapshot of the Selection state (determined by getSelection()).
+
+        (DOMUpdate):
+        (DOMUpdate.prototype.apply):
+        (DOMUpdate.prototype.unapply):
+        (DOMUpdate.prototype.targetNode):
+        (DOMUpdate.prototype.detailsElement):
+        (DOMUpdate.ofType):
+        (DOMUpdate.fromRecords):
+
+        A DOMUpdate is an abstract object representing a change in the DOM that may be applied and unapplied. These are
+        also serializable as hashes, which may then be converted to JSON when generating editing history data.
+
+        (ChildListUpdate):
+        (ChildListUpdate.prototype.apply):
+        (ChildListUpdate.prototype.unapply):
+        (ChildListUpdate.prototype._nextSibling):
+        (ChildListUpdate.prototype._removedNodes):
+        (ChildListUpdate.prototype._addedNodes):
+        (ChildListUpdate.prototype.toObject):
+        (ChildListUpdate.prototype.detailsElement):
+        (ChildListUpdate.fromObject):
+
+        These three update types correspond to the three types of DOM mutations. These may appear as top-level updates
+        if they are not captured during an input event, but for the majority of user-input-driven changes, they will be
+        children of an input event.
+
+        (CharacterDataUpdate):
+        (CharacterDataUpdate.prototype.apply):
+        (CharacterDataUpdate.prototype.unapply):
+        (CharacterDataUpdate.prototype.detailsElement):
+        (CharacterDataUpdate.prototype.toObject):
+        (CharacterDataUpdate.fromObject):
+        (AttributeUpdate):
+        (AttributeUpdate.prototype.apply):
+        (AttributeUpdate.prototype.unapply):
+        (AttributeUpdate.prototype.detailsElement):
+        (AttributeUpdate.prototype.toObject):
+        (AttributeUpdate.fromObject):
+        (SelectionUpdate):
+        (SelectionUpdate.prototype.apply):
+        (SelectionUpdate.prototype.unapply):
+        (SelectionUpdate.prototype.toObject):
+        (SelectionUpdate.fromObject):
+        (SelectionUpdate.prototype._rangeDescriptionHTML):
+        (SelectionUpdate.prototype._anchorDescriptionHTML):
+        (SelectionUpdate.prototype._focusDescriptionHTML):
+        (SelectionUpdate.prototype.detailsElement):
+
+        Represents a change in the Selection. While no changes to the DOM structure occur as a result of a
+        SelectionUpdate, the information contained in these updates is used to determine where the selection should be
+        when rewinding or playing back the editing history.
+
+        (InputEventUpdate):
+        (InputEventUpdate.prototype._obfuscatedData):
+        (InputEventUpdate.prototype.apply):
+        (InputEventUpdate.prototype.unapply):
+        (InputEventUpdate.prototype.toObject):
+        (InputEventUpdate.fromObject):
+        (InputEventUpdate.prototype.detailsElement):
+
+        Represents an update due to user input, which consists of some number of child DOM mutation updates.
+
+        * WebCore.xcodeproj/project.pbxproj:
+
 2016-12-07  Jer Noble  <jer.noble@apple.com>
 
         ASSERT crash while running media-source/mediasource-activesourcebuffers.html under Stress GC bot.
diff --git a/Source/WebCore/InternalScripts/DumpEditingHistory.js b/Source/WebCore/InternalScripts/DumpEditingHistory.js
new file mode 100644 (file)
index 0000000..5edc5ec
--- /dev/null
@@ -0,0 +1,93 @@
+(() => {
+    let initialized = false;
+    let globalNodeMap = new EditingHistory.GlobalNodeMap();
+    let topLevelUpdates = [];
+    let currentChildUpdates = [];
+    let isProcessingTopLevelUpdate = false;
+    let lastKnownSelectionState = null;
+    let mutationObserver = new MutationObserver(records => appendDOMUpdatesFromRecords(records));
+
+    function beginProcessingTopLevelUpdate() {
+        isProcessingTopLevelUpdate = true;
+    }
+
+    function endProcessingTopLevelUpdate(topLevelUpdate) {
+        topLevelUpdates.push(topLevelUpdate);
+        currentChildUpdates = [];
+        isProcessingTopLevelUpdate = false;
+    }
+
+    function appendDOMUpdatesFromRecords(records) {
+        if (!records.length)
+            return;
+
+        let newUpdates = EditingHistory.DOMUpdate.fromRecords(records, globalNodeMap);
+        if (isProcessingTopLevelUpdate)
+            currentChildUpdates = currentChildUpdates.concat(newUpdates);
+        else
+            topLevelUpdates = topLevelUpdates.concat(newUpdates);
+    }
+
+    function appendSelectionUpdateIfNecessary() {
+        let newSelectionState = EditingHistory.SelectionState.fromSelection(getSelection(), globalNodeMap);
+        if (newSelectionState.isEqual(lastKnownSelectionState))
+            return;
+
+        let update = new EditingHistory.SelectionUpdate(globalNodeMap, newSelectionState);
+        if (isProcessingTopLevelUpdate)
+            currentChildUpdates.push(update);
+        else
+            topLevelUpdates.push(update);
+        lastKnownSelectionState = newSelectionState;
+    }
+
+    document.body.setAttribute("contenteditable", true);
+    document.body.addEventListener("focus", () => {
+        if (initialized)
+            return;
+
+        initialized = true;
+
+        EditingHistory.getEditingHistoryAsJSONString = (formatted) => {
+            let record = {};
+            record.updates = topLevelUpdates.map(update => update.toObject());
+            record.globalNodeMap = globalNodeMap.toObject();
+            return formatted ? JSON.stringify(record, null, 4) : JSON.stringify(record);
+        };
+
+        document.addEventListener("selectionchange", () => {
+            appendSelectionUpdateIfNecessary();
+        });
+        document.addEventListener("beforeinput", event => {
+            appendDOMUpdatesFromRecords(mutationObserver.takeRecords());
+            beginProcessingTopLevelUpdate();
+        });
+
+        document.addEventListener("input", event => {
+            appendDOMUpdatesFromRecords(mutationObserver.takeRecords());
+            let eventData = event.dataTransfer ? event.dataTransfer.getData("text/html") : event.data;
+            lastKnownSelectionState = null;
+            endProcessingTopLevelUpdate(new EditingHistory.InputEventUpdate(globalNodeMap, currentChildUpdates, event.inputType, eventData, event.timeStamp));
+        });
+
+        document.addEventListener("keydown", event => {
+            if (event.key !== "s" || !event.metaKey)
+                return;
+
+            let fakeLink = document.createElement("a");
+            fakeLink.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(EditingHistory.getEditingHistoryAsJSONString()));
+            fakeLink.setAttribute("download", "record.json");
+            fakeLink.click();
+            event.preventDefault();
+        });
+
+        mutationObserver.observe(document, {
+            childList: true,
+            attributes: true,
+            characterData: true,
+            subtree: true,
+            attributeOldValue: true,
+            characterDataOldValue: true,
+        });
+    });
+})();
diff --git a/Source/WebCore/InternalScripts/EditingHistoryUtil.js b/Source/WebCore/InternalScripts/EditingHistoryUtil.js
new file mode 100644 (file)
index 0000000..645870b
--- /dev/null
@@ -0,0 +1,693 @@
+(() => {
+    class Obfuscator {
+        constructor() {
+            this._scrambledLowercaseLetters = this._scramble(Array(26).fill().map((_, i) => 97 + i));
+            this._scrambledUppercaseLetters = this._scramble(Array(26).fill().map((_, i) => 65 + i));
+            this._scrambledNumbers = this._scramble(Array(10).fill().map((_, i) => 48 + i));
+            this.enabled = false;
+        }
+
+        _scramble(array) {
+            for (var i = array.length - 1; i > 0; i--) {
+                let j = Math.floor(Math.random() * (i + 1));
+                let temp = array[i];
+                array[i] = array[j];
+                array[j] = temp;
+            }
+            return array;
+        }
+
+        applyToText(text) {
+            if (!this.enabled || !text)
+                return text;
+
+            let result = "";
+            for (let index = 0; index < text.length; index++) {
+                let code = text.charCodeAt(index);
+                let numberIndex = this._scrambedNumberIndexForCode(code);
+                let lowercaseIndex = this._scrambedLowercaseIndexForCode(code);
+                let uppercaseIndex = this._scrambedUppercaseIndexForCode(code);
+
+                if (numberIndex != null)
+                    result += String.fromCharCode(this._scrambledNumbers[numberIndex]);
+                else if (lowercaseIndex != null)
+                    result += String.fromCharCode(this._scrambledLowercaseLetters[lowercaseIndex]);
+                else if (uppercaseIndex != null)
+                    result += String.fromCharCode(this._scrambledUppercaseLetters[uppercaseIndex]);
+                else
+                    result += text.charAt(index);
+            }
+            return result;
+        }
+
+        applyToFilename(filename) {
+            if (!this.enabled || !filename)
+                return filename;
+
+            let components = filename.split(".");
+            return components.map((component, index) => {
+                if (index == components.length - 1)
+                    return component;
+
+                return this.applyToText(component);
+            }).join(".");
+        }
+
+        _scrambedNumberIndexForCode(code) {
+            return 48 <= code && code <= 57 ? code - 48 : null;
+        }
+
+        _scrambedLowercaseIndexForCode(code) {
+            return 97 <= code && code <= 122 ? code - 97 : null;
+        }
+
+        _scrambedUppercaseIndexForCode(code) {
+            return 65 <= code && code <= 90 ? code - 65 : null;
+        }
+
+        static shared() {
+            if (!Obfuscator._sharedInstance)
+                Obfuscator._sharedInstance = new Obfuscator();
+            return Obfuscator._sharedInstance;
+        }
+    }
+
+    function elementFromMarkdown(html) {
+        let temporaryDiv = document.createElement("div");
+        temporaryDiv.innerHTML = html;
+        return temporaryDiv.children[0];
+    }
+
+    class GlobalNodeMap {
+        constructor(nodesByGUID) {
+            this._nodesByGUID = nodesByGUID ? nodesByGUID : new Map();
+            this._guidsByNode = new Map();
+            this._currentGUID = 0;
+            for (let [guid, node] of this._nodesByGUID) {
+                this._guidsByNode.set(node, guid);
+                this._currentGUID = Math.max(this._currentGUID, guid);
+            }
+            this._currentGUID++;
+        }
+
+        nodesForGUIDs(guids) {
+            if (!guids.map)
+                guids = Array.from(guids);
+            return guids.map(guid => this.nodeForGUID(guid));
+        }
+
+        guidsForNodes(nodes) {
+            if (!nodes.map)
+                nodes = Array.from(nodes);
+            return nodes.map(node => this.guidForNode(node));
+        }
+
+        nodeForGUID(guid) {
+            if (!guid)
+                return null;
+
+            return this._nodesByGUID.get(guid);
+        }
+
+        guidForNode(node) {
+            if (!node)
+                return 0;
+
+            if (this.hasGUIDForNode(node))
+                return this._guidsByNode.get(node);
+
+            const guid = this._currentGUID;
+            this._guidsByNode.set(node, guid);
+            this._nodesByGUID.set(guid, node);
+            this._currentGUID++;
+            return guid;
+        }
+
+        hasGUIDForNode(node) {
+            return !!this._guidsByNode.get(node);
+        }
+
+        nodes() {
+            return Array.from(this._nodesByGUID.values());
+        }
+
+        toObject() {
+            let nodesAndGUIDsToProcess = [], guidsToProcess = new Set();
+            let guidsByNodeIterator = this._guidsByNode.entries();
+            for (let entry = guidsByNodeIterator.next(); !entry.done; entry = guidsByNodeIterator.next()) {
+                nodesAndGUIDsToProcess.push(entry.value);
+                guidsToProcess.add(entry.value[1]);
+            }
+
+            let iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ALL);
+            for (let node = iterator.nextNode(); node; node = iterator.nextNode()) {
+                if (this.hasGUIDForNode(node))
+                    continue;
+
+                let newGUID = this.guidForNode(node);
+                nodesAndGUIDsToProcess.push([node, newGUID]);
+                guidsToProcess.add(newGUID);
+            }
+
+            let nodeInfoArray = [];
+            while (nodesAndGUIDsToProcess.length) {
+                let [node, guid] = nodesAndGUIDsToProcess.pop();
+                let info = {};
+                info.guid = guid;
+                info.tagName = node.tagName;
+                info.attributes = GlobalNodeMap.nodeAttributesToObject(node);
+                info.type = node.nodeType;
+                info.data = GlobalNodeMap.dataForNode(node);
+                if (node.hasChildNodes()) {
+                    info.childGUIDs = this.guidsForNodes(node.childNodes);
+                    for (let childGUID of info.childGUIDs) {
+                        if (!guidsToProcess.has(childGUID))
+                            nodesAndGUIDsToProcess.push([this.nodeForGUID(childGUID), childGUID]);
+                    }
+                }
+                nodeInfoArray.push(info);
+            }
+
+            return nodeInfoArray;
+        }
+
+        static fromObject(nodeInfoArray) {
+            let nodesByGUID = new Map();
+            for (let info of nodeInfoArray) {
+                let node = null;
+                if (info.type == Node.ELEMENT_NODE)
+                    node = GlobalNodeMap.elementFromTagName(info.tagName, info.attributes, info.data);
+
+                if (info.type == Node.TEXT_NODE)
+                    node = document.createTextNode(info.data);
+
+                if (info.type == Node.DOCUMENT_NODE)
+                    node = document;
+
+                console.assert(node);
+                nodesByGUID.set(info.guid, node);
+            }
+
+            // Then, set child nodes for all nodes that do not appear in the DOM.
+            for (let info of nodeInfoArray.filter(info => !!info.childGUIDs)) {
+                let node = nodesByGUID.get(info.guid);
+                for (let childGUID of info.childGUIDs)
+                     node.appendChild(nodesByGUID.get(childGUID));
+            }
+
+            return new GlobalNodeMap(nodesByGUID);
+        }
+
+        static dataForNode(node) {
+            if (node.nodeType === Node.TEXT_NODE)
+                return Obfuscator.shared().applyToText(node.data);
+
+            if (node.tagName && node.tagName.toLowerCase() === "attachment") {
+                return {
+                    type: node.file.type,
+                    name: Obfuscator.shared().applyToFilename(node.file.name),
+                    lastModified: new Date().getTime()
+                };
+            }
+
+            return null;
+        }
+
+        static elementFromTagName(tagName, attributes, data) {
+            let node = document.createElement(tagName);
+            for (let attributeName in attributes)
+                node.setAttribute(attributeName, attributes[attributeName]);
+
+            if (tagName.toLowerCase() == "attachment") {
+                node.file = new File([`File named '${data.name}'`], data.name, {
+                    type: data.type,
+                    lastModified: data.lastModified
+                });
+            }
+
+            return node;
+        }
+
+        // Returns an Object containing attribute name => attribute value
+        static nodeAttributesToObject(node, attributesToExclude=[]) {
+            const excludeAttributesSet = new Set(attributesToExclude);
+            if (!node.attributes)
+                return null;
+
+            let attributeMap = {};
+            for (let index = 0; index < node.attributes.length; index++) {
+                const attribute = node.attributes.item(index);
+                const [localName, value] = [attribute.localName, attribute.value];
+                if (excludeAttributesSet.has(localName))
+                    continue;
+
+                attributeMap[localName] = value;
+            }
+
+            return attributeMap;
+        }
+
+        descriptionHTMLForGUID(guid) {
+            return `<span eh-guid=${guid} class="eh-node">${this.nodeForGUID(guid).nodeName}</span>`;
+        }
+
+        descriptionHTMLForNode(node) {
+            if (!node)
+                return "(null)";
+            return `<span eh-guid=${this.guidForNode(node)} class="eh-node">${node.nodeName}</span>`;
+        }
+    }
+
+    class SelectionState {
+        constructor(nodeMap, startNode, startOffset, endNode, endOffset, anchorNode, anchorOffset, focusNode, focusOffset) {
+            console.assert(nodeMap);
+            this.nodeMap = nodeMap;
+            this.startGUID = nodeMap.guidForNode(startNode);
+            this.startOffset = startOffset;
+            this.endGUID = nodeMap.guidForNode(endNode);
+            this.endOffset = endOffset;
+            this.anchorGUID = nodeMap.guidForNode(anchorNode);
+            this.anchorOffset = anchorOffset;
+            this.focusGUID = nodeMap.guidForNode(focusNode);
+            this.focusOffset = focusOffset;
+        }
+
+        isEqual(otherSelectionState) {
+            return otherSelectionState
+                && this.startGUID === otherSelectionState.startGUID && this.startOffset === otherSelectionState.startOffset
+                && this.endGUID === otherSelectionState.endGUID && this.endOffset === otherSelectionState.endOffset
+                && this.anchorGUID === otherSelectionState.anchorGUID && this.anchorOffset === otherSelectionState.anchorOffset
+                && this.focusGUID === otherSelectionState.focusGUID && this.focusOffset === otherSelectionState.focusOffset;
+        }
+
+        applyToSelection(selection) {
+            selection.removeAllRanges();
+            let range = document.createRange();
+            range.setStart(this.nodeMap.nodeForGUID(this.startGUID), this.startOffset);
+            range.setEnd(this.nodeMap.nodeForGUID(this.endGUID), this.endOffset);
+            selection.addRange(range);
+            selection.setBaseAndExtent(this.nodeMap.nodeForGUID(this.anchorGUID), this.anchorOffset, this.nodeMap.nodeForGUID(this.focusGUID), this.focusOffset);
+        }
+
+        static fromSelection(selection, nodeMap) {
+            let [startNode, startOffset, endNode, endOffset] = [null, 0, null, 0];
+            if (selection.rangeCount) {
+                let selectedRange = selection.getRangeAt(0);
+                startNode = selectedRange.startContainer;
+                startOffset = selectedRange.startOffset;
+                endNode = selectedRange.endContainer;
+                endOffset = selectedRange.endOffset;
+            }
+            return new SelectionState(
+                nodeMap, startNode, startOffset, endNode, endOffset,
+                selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset
+            );
+        }
+
+        toObject() {
+            return {
+                startGUID: this.startGUID, startOffset: this.startOffset, endGUID: this.endGUID, endOffset: this.endOffset,
+                anchorGUID: this.anchorGUID, anchorOffset: this.anchorOffset, focusGUID: this.focusGUID, focusOffset: this.focusOffset
+            };
+        }
+
+        static fromObject(json, nodeMap) {
+            if (!json)
+                return null;
+
+            return new SelectionState(
+                nodeMap, nodeMap.nodeForGUID(json.startGUID), json.startOffset, nodeMap.nodeForGUID(json.endGUID), json.endOffset,
+                nodeMap.nodeForGUID(json.anchorGUID), json.anchorOffset, nodeMap.nodeForGUID(json.focusGUID), json.focusOffset
+            );
+        }
+    }
+
+    class DOMUpdate {
+        constructor(nodeMap) {
+            console.assert(nodeMap);
+            this.nodeMap = nodeMap;
+        }
+
+        apply() {
+            throw "Expected subclass implementation.";
+        }
+
+        unapply() {
+            throw "Expected subclass implementation.";
+        }
+
+        targetNode() {
+            return this.nodeMap.nodeForGUID(this.targetGUID);
+        }
+
+        detailsElement() {
+            throw "Expected subclass implementation.";
+        }
+
+        static ofType(type) {
+            if (!DOMUpdate._allTypes)
+                DOMUpdate._allTypes = { ChildListUpdate, CharacterDataUpdate, AttributeUpdate, InputEventUpdate, SelectionUpdate };
+            return DOMUpdate._allTypes[type];
+        }
+
+        static fromRecords(records, nodeMap) {
+            let updates = []
+                , characterDataUpdates = []
+                , attributeUpdates = [];
+
+            for (let record of records) {
+                let target = record.target;
+                switch (record.type) {
+                case "characterData":
+                    var update = new CharacterDataUpdate(nodeMap, nodeMap.guidForNode(target), record.oldValue, target.data)
+                    updates.push(update);
+                    characterDataUpdates.push(update);
+                    break;
+                case "childList":
+                    var update = new ChildListUpdate(nodeMap, nodeMap.guidForNode(target), nodeMap.guidsForNodes(record.addedNodes), nodeMap.guidsForNodes(record.removedNodes), nodeMap.guidForNode(record.nextSibling))
+                    updates.push(update);
+                    break;
+                case "attributes":
+                    var update = new AttributeUpdate(nodeMap, nodeMap.guidForNode(target), record.attributeName, record.oldValue, target.getAttribute(record.attributeName))
+                    updates.push(update);
+                    attributeUpdates.push(update);
+                    break;
+                }
+            }
+
+            // Adjust all character data updates for the same target.
+            characterDataUpdates.forEach((currentUpdate, index) => {
+                if (index == characterDataUpdates.length - 1)
+                    return;
+
+                for (let nextUpdateIndex = index + 1; nextUpdateIndex < characterDataUpdates.length; nextUpdateIndex++) {
+                    let nextUpdate = characterDataUpdates[nextUpdateIndex];
+                    if (currentUpdate.targetGUID === nextUpdate.targetGUID) {
+                        currentUpdate.newData = nextUpdate.oldData;
+                        break;
+                    }
+                }
+            });
+
+            // Adjust all attribute updates for the same target and attribute name.
+            attributeUpdates.forEach((currentUpdate, index) => {
+                if (index == attributeUpdates.length - 1)
+                    return;
+
+                for (let nextUpdateIndex = index + 1; nextUpdateIndex < attributeUpdates.length; nextUpdateIndex++) {
+                    let nextUpdate = attributeUpdates[nextUpdateIndex];
+                    if (currentUpdate.targetGUID === nextUpdate.targetGUID && currentUpdate.attribute === nextUpdate.attribute) {
+                        currentUpdate.newData = nextUpdate.oldData;
+                        break;
+                    }
+                }
+            });
+
+            return updates;
+        }
+    }
+
+    class ChildListUpdate extends DOMUpdate {
+        constructor(nodeMap, targetGUID, addedGUIDs, removedGUIDs, nextSiblingGUID) {
+            super(nodeMap);
+            this.targetGUID = targetGUID;
+            this.added = addedGUIDs;
+            this.removed = removedGUIDs;
+            this.nextSiblingGUID = nextSiblingGUID == undefined ? null : nextSiblingGUID;
+            console.assert(nodeMap.nodeForGUID(targetGUID));
+        }
+
+        apply() {
+            for (let removedNode of this._removedNodes())
+                removedNode.remove();
+
+            let target = this.targetNode();
+            for (let addedNode of this._addedNodes())
+                target.insertBefore(addedNode, this._nextSibling());
+        }
+
+        unapply() {
+            for (let addedNode of this._addedNodes())
+                addedNode.remove();
+
+            let target = this.targetNode();
+            for (let removedNode of this._removedNodes())
+                target.insertBefore(removedNode, this._nextSibling());
+        }
+
+        _nextSibling() {
+            if (this.nextSiblingGUID == null)
+                return null;
+            return this.nodeMap.nodeForGUID(this.nextSiblingGUID);
+        }
+
+        _removedNodes() {
+            return this.nodeMap.nodesForGUIDs(this.removed);
+        }
+
+        _addedNodes() {
+            return this.nodeMap.nodesForGUIDs(this.added);
+        }
+
+        toObject() {
+            return {
+                type: "ChildListUpdate",
+                targetGUID: this.targetGUID,
+                addedGUIDs: this.added,
+                removedGUIDs: this.removed,
+                nextSiblingGUID: this.nextSiblingGUID
+            };
+        }
+
+        detailsElement() {
+            let nextSibling = this._nextSibling();
+            let html =
+            `<details>
+                <summary>child list changed</summary>
+                <ul>
+                    <li>parent: ${this.nodeMap.descriptionHTMLForGUID(this.targetGUID)}</li>
+                    <li>added: [ ${[this._addedNodes().map(node => this.nodeMap.descriptionHTMLForNode(node))]} ]</li>
+                    <li>removed: [ ${[this._removedNodes().map(node => this.nodeMap.descriptionHTMLForNode(node))]} ]</li>
+                    <li>before sibling: ${nextSibling ? this.nodeMap.descriptionHTMLForNode(nextSibling) : "(null)"}</li>
+                </ul>
+            </details>`;
+            return elementFromMarkdown(html);
+        }
+
+        static fromObject(json, nodeMap) {
+            return new ChildListUpdate(nodeMap, json.targetGUID, json.addedGUIDs, json.removedGUIDs, json.nextSiblingGUID);
+        }
+    }
+
+    class CharacterDataUpdate extends DOMUpdate {
+        constructor(nodeMap, targetGUID, oldData, newData) {
+            super(nodeMap);
+            this.targetGUID = targetGUID;
+            this.oldData = oldData;
+            this.newData = newData;
+            console.assert(nodeMap.nodeForGUID(targetGUID));
+        }
+
+        apply() {
+            this.targetNode().data = this.newData;
+        }
+
+        unapply() {
+            this.targetNode().data = this.oldData;
+        }
+
+        detailsElement() {
+            let html =
+            `<details>
+                <summary>character data changed</summary>
+                <ul>
+                    <li>old: ${this.oldData != null ? "'" + this.oldData + "'" : "(null)"}</li>
+                    <li>new: ${this.newData != null ? "'" + this.newData + "'" : "(null)"}</li>
+                </ul>
+            </details>`;
+            return elementFromMarkdown(html);
+        }
+
+        toObject() {
+            return {
+                type: "CharacterDataUpdate",
+                targetGUID: this.targetGUID,
+                oldData: Obfuscator.shared().applyToText(this.oldData),
+                newData: Obfuscator.shared().applyToText(this.newData)
+            };
+        }
+
+        static fromObject(json, nodeMap) {
+            return new CharacterDataUpdate(nodeMap, json.targetGUID, json.oldData, json.newData);
+        }
+    }
+
+    class AttributeUpdate extends DOMUpdate {
+        constructor(nodeMap, targetGUID, attribute, oldValue, newValue) {
+            super(nodeMap);
+            this.targetGUID = targetGUID;
+            this.attribute = attribute;
+            this.oldValue = oldValue;
+            this.newValue = newValue;
+            console.assert(nodeMap.nodeForGUID(targetGUID));
+        }
+
+        apply() {
+            if (this.newValue == null)
+                this.targetNode().removeAttribute(this.attribute);
+            else
+                this.targetNode().setAttribute(this.attribute, this.newValue);
+        }
+
+        unapply() {
+            if (this.oldValue == null)
+                this.targetNode().removeAttribute(this.attribute);
+            else
+                this.targetNode().setAttribute(this.attribute, this.oldValue);
+        }
+
+        detailsElement() {
+            let html =
+            `<details>
+                <summary>attribute changed</summary>
+                <ul>
+                    <li>target: ${this.nodeMap.descriptionHTMLForGUID(this.targetGUID)}</li>
+                    <li>attribute: ${this.attribute}</li>
+                    <li>old: ${this.oldValue != null ? "'" + this.oldValue + "'" : "(null)"}</li>
+                    <li>new: ${this.newValue != null ? "'" + this.newValue + "'" : "(null)"}</li>
+                </ul>
+            </details>`;
+            return elementFromMarkdown(html);
+        }
+
+        toObject() {
+            return {
+                type: "AttributeUpdate",
+                targetGUID: this.targetGUID,
+                attribute: this.attribute,
+                oldValue: this.oldValue,
+                newValue: this.newValue
+            };
+        }
+
+        static fromObject(json, nodeMap) {
+            return new AttributeUpdate(nodeMap, json.targetGUID, json.attribute, json.oldValue, json.newValue);
+        }
+    }
+
+    class SelectionUpdate extends DOMUpdate {
+        constructor(nodeMap, state) {
+            super(nodeMap);
+            this.state = state;
+        }
+
+        // SelectionUpdates are not applied/unapplied by the normal means. The selection is applied via
+        // DOMUpdateHistoryContext.applyCurrentSelectionState instead, which considers the updates before and after the
+        // current update index.
+        apply() { }
+        unapply() { }
+
+        toObject() {
+            return {
+                type: "SelectionUpdate",
+                state: this.state ? this.state.toObject() : null
+            };
+        }
+
+        static fromObject(json, nodeMap) {
+            return new SelectionUpdate(nodeMap, SelectionState.fromObject(json.state, nodeMap));
+        }
+
+        _rangeDescriptionHTML() {
+            return `(${this.nodeMap.descriptionHTMLForGUID(this.state.startGUID)}:${this.state.startOffset},
+                ${this.nodeMap.descriptionHTMLForGUID(this.state.endGUID)}:${this.state.endOffset})`;
+        }
+
+        _anchorDescriptionHTML() {
+            return `${this.nodeMap.descriptionHTMLForGUID(this.state.anchorGUID)}:${this.state.anchorOffset}`;
+        }
+
+        _focusDescriptionHTML() {
+            return `${this.nodeMap.descriptionHTMLForGUID(this.state.focusGUID)}:${this.state.focusOffset}`;
+        }
+
+        detailsElement() {
+            let html =
+            `<details>
+                <summary>Selection changed</summary>
+                <ul>
+                    <li>range: ${this._rangeDescriptionHTML()}</li>
+                    <li>anchor: ${this._anchorDescriptionHTML()}</li>
+                    <li>focus: ${this._focusDescriptionHTML()}</li>
+                </ul>
+            </details>`;
+            return elementFromMarkdown(html);
+        }
+    }
+
+    class InputEventUpdate extends DOMUpdate {
+        constructor(nodeMap, updates, inputType, data, timeStamp) {
+            super(nodeMap);
+            this.updates = updates;
+            this.inputType = inputType;
+            this.data = data;
+            this.timeStamp = timeStamp;
+        }
+
+        _obfuscatedData() {
+            return this.inputType.indexOf("insert") == 0 ? Obfuscator.shared().applyToText(this.data) : this.data;
+        }
+
+        apply() {
+            for (let update of this.updates)
+                update.apply();
+        }
+
+        unapply() {
+            for (let index = this.updates.length - 1; index >= 0; index--)
+                this.updates[index].unapply();
+        }
+
+        toObject() {
+            return {
+                type: "InputEventUpdate",
+                inputType: this.inputType,
+                data: this._obfuscatedData(),
+                timeStamp: this.timeStamp,
+                updates: this.updates.map(update => update.toObject())
+            };
+        }
+
+        static fromObject(json, nodeMap) {
+            let updates = json.updates.map(update => DOMUpdate.ofType(update.type).fromObject(update, nodeMap));
+            return new InputEventUpdate(nodeMap, updates, json.inputType, json.data, json.timeStamp);
+        }
+
+        detailsElement() {
+            let html =
+            `<details>
+                <summary>Input (${this.inputType})</summary>
+                <ul>
+                    <li>time: ${this.timeStamp}</li>
+                    <li>data: ${!this.data ? "(null)" : "'" + this.data + "'"}</li>
+                </ul>
+            </details>`;
+            let topLevelDetails = elementFromMarkdown(html);
+            for (let update of this.updates)
+                topLevelDetails.children[topLevelDetails.childElementCount - 1].appendChild(update.detailsElement());
+            return topLevelDetails;
+        }
+    }
+
+    window.EditingHistory = {
+        GlobalNodeMap,
+        SelectionState,
+        DOMUpdate,
+        ChildListUpdate,
+        CharacterDataUpdate,
+        AttributeUpdate,
+        SelectionUpdate,
+        InputEventUpdate,
+        Obfuscator
+    };
+})();
index c744bdc..8b4e300 100644 (file)
                51C81B8A0C4422F70019ECE3 /* FTPDirectoryParser.h in Headers */ = {isa = PBXBuildFile; fileRef = 51C81B880C4422F70019ECE3 /* FTPDirectoryParser.h */; };
                51CBFC990D10E483002DBF51 /* CachedFramePlatformData.h in Headers */ = {isa = PBXBuildFile; fileRef = 51CBFC980D10E483002DBF51 /* CachedFramePlatformData.h */; settings = {ATTRIBUTES = (Private, ); }; };
                51D0C5160DAA90B7003B3831 /* JSStorageCustom.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 51D0C5150DAA90B7003B3831 /* JSStorageCustom.cpp */; };
+               51D394781DF2492200ABE875 /* DumpEditingHistory.js in Copy Internal Scripts */ = {isa = PBXBuildFile; fileRef = 51D394741DF2454000ABE875 /* DumpEditingHistory.js */; };
+               51D394791DF2492200ABE875 /* EditingHistoryUtil.js in Copy Internal Scripts */ = {isa = PBXBuildFile; fileRef = 51D394751DF2454000ABE875 /* EditingHistoryUtil.js */; };
                51D7236C1BB6174900478CA3 /* IDBResultData.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 51D7236A1BB60BFE00478CA3 /* IDBResultData.cpp */; };
                51D7236D1BB6174900478CA3 /* IDBResultData.h in Headers */ = {isa = PBXBuildFile; fileRef = 51D7236B1BB60BFE00478CA3 /* IDBResultData.h */; settings = {ATTRIBUTES = (Private, ); }; };
                51D7EFEA1BDE8F8C00E93E10 /* ThreadSafeDataBuffer.h in Headers */ = {isa = PBXBuildFile; fileRef = 511FAEA91BDC989A00B4AFE4 /* ThreadSafeDataBuffer.h */; settings = {ATTRIBUTES = (Private, ); }; };
                        name = "Copy Scripts";
                        runOnlyForDeploymentPostprocessing = 0;
                };
+               51D394771DF2486700ABE875 /* Copy Internal Scripts */ = {
+                       isa = PBXCopyFilesBuildPhase;
+                       buildActionMask = 8;
+                       dstPath = "$(APPLE_INTERNAL_LIBRARY_DIR)/WebKit/InternalScripts";
+                       dstSubfolderSpec = 0;
+                       files = (
+                               51D394781DF2492200ABE875 /* DumpEditingHistory.js in Copy Internal Scripts */,
+                               51D394791DF2492200ABE875 /* EditingHistoryUtil.js in Copy Internal Scripts */,
+                       );
+                       name = "Copy Internal Scripts";
+                       runOnlyForDeploymentPostprocessing = 1;
+               };
                CD0DBF001422765700280263 /* Copy Audio Resources */ = {
                        isa = PBXCopyFilesBuildPhase;
                        buildActionMask = 2147483647;
                51C81B880C4422F70019ECE3 /* FTPDirectoryParser.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = FTPDirectoryParser.h; sourceTree = "<group>"; };
                51CBFC980D10E483002DBF51 /* CachedFramePlatformData.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CachedFramePlatformData.h; sourceTree = "<group>"; };
                51D0C5150DAA90B7003B3831 /* JSStorageCustom.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = JSStorageCustom.cpp; sourceTree = "<group>"; };
+               51D394741DF2454000ABE875 /* DumpEditingHistory.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = DumpEditingHistory.js; path = InternalScripts/DumpEditingHistory.js; sourceTree = "<group>"; };
+               51D394751DF2454000ABE875 /* EditingHistoryUtil.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = EditingHistoryUtil.js; path = InternalScripts/EditingHistoryUtil.js; sourceTree = "<group>"; };
                51D7196C181106DF0016DC51 /* DOMWindowIndexedDatabase.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = DOMWindowIndexedDatabase.cpp; sourceTree = "<group>"; };
                51D7196D181106DF0016DC51 /* DOMWindowIndexedDatabase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DOMWindowIndexedDatabase.h; sourceTree = "<group>"; };
                51D7196E181106DF0016DC51 /* DOMWindowIndexedDatabase.idl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = DOMWindowIndexedDatabase.idl; sourceTree = "<group>"; };
                                2E4346310F546A6800B0F1BA /* workers */,
                                E1F0424309839389006694EA /* xml */,
                                656580EC09D12B20000E61D7 /* Derived Sources */,
+                               51D394731DF244BA00ABE875 /* InternalScripts */,
                                089C1665FE841158C02AAC07 /* Resources */,
                                3717D7E417ECC36C003C276D /* Scripts */,
                                0867D69AFE84028FC02AAC07 /* Frameworks */,
                        path = mac;
                        sourceTree = "<group>";
                };
+               51D394731DF244BA00ABE875 /* InternalScripts */ = {
+                       isa = PBXGroup;
+                       children = (
+                               51D394741DF2454000ABE875 /* DumpEditingHistory.js */,
+                               51D394751DF2454000ABE875 /* EditingHistoryUtil.js */,
+                       );
+                       name = InternalScripts;
+                       sourceTree = "<group>";
+               };
                59B5977111086556007159E8 /* jsc */ = {
                        isa = PBXGroup;
                        children = (
                                37A1EAA3142699BC0087F425 /* Check For Inappropriate Objective-C Class Names */,
                                5DF50887116F3077005202AB /* Check For Inappropriate Files In Framework */,
                                71D6AA381DA4E69400B23969 /* Copy modern media controls code and assets */,
+                               51D394771DF2486700ABE875 /* Copy internal scripts */,
                        );
                        buildRules = (
                        );
index af7e89f..4a06433 100644 (file)
@@ -1,3 +1,80 @@
+2016-12-07  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        Add a new project for recording and playing back editing commands in editable web content
+        https://bugs.webkit.org/show_bug.cgi?id=165114
+        <rdar://problem/29408135>
+
+        Reviewed by Beth Dakin.
+
+        Adds a new Xcode project containing work towards rewinding and playing back editing commands. This work is
+        wrapped in an Xcode project to take advantage of the XCTest framework. To manually test recording, open the
+        capture test harness, edit the contenteditable body, and then hit cmd-S. This downloads a .json file which may
+        then be dragged into the playback test harness.
+
+        Also adds 3 new unit tests in EditingHistoryTests/RewindAndPlaybackTests.m. These tests carry out the following
+        steps:
+
+        1. Load the capture harness and perform test-specific editing on the web view.
+        2. Let originalState be a dump of the DOM at this point in time.
+        3. Extract the JSON-serialized editing history data and load the playback harness with this data.
+        4. Rewind all editing to the beginning.
+        5. Playback all editing to the end.
+        6. Dump the state of the DOM. This should be identical to originalState.
+
+        * EditingHistory/EditingHistory.xcodeproj/project.pbxproj: Added.
+        * EditingHistory/EditingHistory/Info.plist: Added.
+        * EditingHistory/EditingHistory/Resources/CaptureHarness.html: Added.
+        * EditingHistory/EditingHistory/Resources/DOMTestingUtil.js: Added.
+        * EditingHistory/EditingHistory/Resources/PlaybackHarness.html: Added.
+        * EditingHistory/EditingHistory/TestRunner.h: Added.
+        * EditingHistory/EditingHistory/TestRunner.m: Added.
+        (injectedMessageEventHandlerScript):
+        (-[TestRunner init]):
+        (-[TestRunner deleteBackwards:]):
+        (-[TestRunner typeString:]):
+        (-[TestRunner bodyElementSubtree]):
+        (-[TestRunner bodyTextContent]):
+        (-[TestRunner editingHistoryJSON]):
+        (-[TestRunner loadPlaybackTestHarnessWithJSON:]):
+        (-[TestRunner numberOfUpdates]):
+        (-[TestRunner jumpToUpdateIndex:]):
+        (-[TestRunner expectEvents:afterPerforming:]):
+        (-[TestRunner loadCaptureTestHarness]):
+        (-[TestRunner setTextObfuscationEnabled:]):
+        (-[TestRunner isDoneWaitingForPendingEvents]):
+        (-[TestRunner userContentController:didReceiveScriptMessage:]):
+
+        The TestRunner provides utilities that a unit test should use to drive the test forward (e.g. loading harnesses)
+        or inspect the state of the loaded page (e.g. extracting JSON editing history data from the capture harness).
+
+        * EditingHistory/EditingHistory/TestUtil.h: Added.
+        * EditingHistory/EditingHistory/TestUtil.m: Added.
+        (waitUntilWithTimeout):
+        (waitUntil):
+
+        Provides utilities for running tests. For now, this is just spinning the runloop on a given condition.
+
+        * EditingHistory/EditingHistory/WKWebViewAdditions.h: Added.
+        * EditingHistory/EditingHistory/WKWebViewAdditions.m: Added.
+        (-[WKWebView loadPageFromBundleNamed:]):
+        (-[WKWebView typeCharacter:]):
+        (-[WKWebView keyPressWithCharacters:keyCode:]):
+        (-[WKWebView stringByEvaluatingJavaScriptFromString:]):
+
+        Provides utilities for simulating interaction in a web view.
+
+        * EditingHistory/EditingHistory/main.m: Added.
+        (main):
+        * EditingHistory/EditingHistoryTests/Info.plist: Added.
+        * EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m: Added.
+        (-[RewindAndPlaybackTests setUp]):
+        (-[RewindAndPlaybackTests tearDown]):
+        (-[RewindAndPlaybackTests testTypingSingleLineOfText]):
+        (-[RewindAndPlaybackTests testTypingMultipleLinesOfText]):
+        (-[RewindAndPlaybackTests testTypingAndDeletingText]):
+        (-[RewindAndPlaybackTests rewindAndPlaybackEditingInPlaybackTestHarness]):
+        (-[RewindAndPlaybackTests originalBodySubtree:isEqualToFinalSubtree:]):
+
 2016-12-07  Philippe Normand  <pnormand@igalia.com>
 
         [GTK][jhbuild] missing dependency on libvpx in gst-plugins-good
diff --git a/Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj b/Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj
new file mode 100644 (file)
index 0000000..d234bb8
--- /dev/null
@@ -0,0 +1,433 @@
+// !$*UTF8*$!
+{
+       archiveVersion = 1;
+       classes = {
+       };
+       objectVersion = 46;
+       objects = {
+
+/* Begin PBXBuildFile section */
+               516ADBE21DE156A900E2B98D /* CaptureHarness.html in Resources */ = {isa = PBXBuildFile; fileRef = 516ADBA81DE155AB00E2B98D /* CaptureHarness.html */; };
+               516ADBE51DE156A900E2B98D /* PlaybackHarness.html in Resources */ = {isa = PBXBuildFile; fileRef = 516ADBAB1DE155AB00E2B98D /* PlaybackHarness.html */; };
+               516ADBE61DE156BB00E2B98D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 516ADBB11DE155BD00E2B98D /* main.m */; };
+               516ADBF31DE157AD00E2B98D /* RewindAndPlaybackTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 516ADBF21DE157AD00E2B98D /* RewindAndPlaybackTests.m */; };
+               517FD93C1DE18DC900A73673 /* DOMTestingUtil.js in Resources */ = {isa = PBXBuildFile; fileRef = 517FD93B1DE18DC900A73673 /* DOMTestingUtil.js */; };
+               51D394801DF2541D00ABE875 /* DumpEditingHistory.js in Resources */ = {isa = PBXBuildFile; fileRef = 51D3947E1DF2541D00ABE875 /* DumpEditingHistory.js */; };
+               51D394811DF2541D00ABE875 /* EditingHistoryUtil.js in Resources */ = {isa = PBXBuildFile; fileRef = 51D3947F1DF2541D00ABE875 /* EditingHistoryUtil.js */; };
+               51ECC3E71DEE33CE00CB267E /* TestUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = 516ADBB51DE155BD00E2B98D /* TestUtil.m */; };
+               51ECC3E91DEE33D200CB267E /* WKWebViewAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 516ADBB71DE155BD00E2B98D /* WKWebViewAdditions.m */; };
+               51ECC3EA1DEE33DD00CB267E /* TestRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = 516ADBB31DE155BD00E2B98D /* TestRunner.m */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+               516ADBF51DE157AD00E2B98D /* PBXContainerItemProxy */ = {
+                       isa = PBXContainerItemProxy;
+                       containerPortal = 512D7A8B1DE0FBEF0028F0E6 /* Project object */;
+                       proxyType = 1;
+                       remoteGlobalIDString = 516ADBC01DE155FC00E2B98D;
+                       remoteInfo = EditingHistory;
+               };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+               516ADBA81DE155AB00E2B98D /* CaptureHarness.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = CaptureHarness.html; path = EditingHistory/Resources/CaptureHarness.html; sourceTree = SOURCE_ROOT; };
+               516ADBAB1DE155AB00E2B98D /* PlaybackHarness.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = PlaybackHarness.html; path = EditingHistory/Resources/PlaybackHarness.html; sourceTree = SOURCE_ROOT; };
+               516ADBB01DE155BD00E2B98D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = EditingHistory/Info.plist; sourceTree = SOURCE_ROOT; };
+               516ADBB11DE155BD00E2B98D /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = EditingHistory/main.m; sourceTree = SOURCE_ROOT; };
+               516ADBB21DE155BD00E2B98D /* TestRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TestRunner.h; path = EditingHistory/TestRunner.h; sourceTree = SOURCE_ROOT; };
+               516ADBB31DE155BD00E2B98D /* TestRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TestRunner.m; path = EditingHistory/TestRunner.m; sourceTree = SOURCE_ROOT; };
+               516ADBB41DE155BD00E2B98D /* TestUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TestUtil.h; path = EditingHistory/TestUtil.h; sourceTree = SOURCE_ROOT; };
+               516ADBB51DE155BD00E2B98D /* TestUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TestUtil.m; path = EditingHistory/TestUtil.m; sourceTree = SOURCE_ROOT; };
+               516ADBB61DE155BD00E2B98D /* WKWebViewAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = WKWebViewAdditions.h; path = EditingHistory/WKWebViewAdditions.h; sourceTree = SOURCE_ROOT; };
+               516ADBB71DE155BD00E2B98D /* WKWebViewAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = WKWebViewAdditions.m; path = EditingHistory/WKWebViewAdditions.m; sourceTree = SOURCE_ROOT; };
+               516ADBC11DE155FC00E2B98D /* EditingHistory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EditingHistory.app; sourceTree = BUILT_PRODUCTS_DIR; };
+               516ADBF01DE157AD00E2B98D /* EditingHistoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EditingHistoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+               516ADBF21DE157AD00E2B98D /* RewindAndPlaybackTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RewindAndPlaybackTests.m; sourceTree = "<group>"; };
+               516ADBF41DE157AD00E2B98D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+               517FD93B1DE18DC900A73673 /* DOMTestingUtil.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = DOMTestingUtil.js; path = EditingHistory/Resources/DOMTestingUtil.js; sourceTree = SOURCE_ROOT; };
+               51D3947E1DF2541D00ABE875 /* DumpEditingHistory.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = DumpEditingHistory.js; path = ../../Source/WebCore/InternalScripts/DumpEditingHistory.js; sourceTree = SOURCE_ROOT; };
+               51D3947F1DF2541D00ABE875 /* EditingHistoryUtil.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = EditingHistoryUtil.js; path = ../../Source/WebCore/InternalScripts/EditingHistoryUtil.js; sourceTree = SOURCE_ROOT; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+               516ADBBE1DE155FC00E2B98D /* Frameworks */ = {
+                       isa = PBXFrameworksBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+               516ADBED1DE157AD00E2B98D /* Frameworks */ = {
+                       isa = PBXFrameworksBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+               512D7A8A1DE0FBEF0028F0E6 = {
+                       isa = PBXGroup;
+                       children = (
+                               512D7A951DE0FBEF0028F0E6 /* EditingHistory */,
+                               516ADBF11DE157AD00E2B98D /* EditingHistoryTests */,
+                               512D7A941DE0FBEF0028F0E6 /* Products */,
+                       );
+                       sourceTree = "<group>";
+               };
+               512D7A941DE0FBEF0028F0E6 /* Products */ = {
+                       isa = PBXGroup;
+                       children = (
+                               516ADBC11DE155FC00E2B98D /* EditingHistory.app */,
+                               516ADBF01DE157AD00E2B98D /* EditingHistoryTests.xctest */,
+                       );
+                       name = Products;
+                       sourceTree = "<group>";
+               };
+               512D7A951DE0FBEF0028F0E6 /* EditingHistory */ = {
+                       isa = PBXGroup;
+                       children = (
+                               516ADBB01DE155BD00E2B98D /* Info.plist */,
+                               516ADBB11DE155BD00E2B98D /* main.m */,
+                               516ADBB21DE155BD00E2B98D /* TestRunner.h */,
+                               516ADBB31DE155BD00E2B98D /* TestRunner.m */,
+                               516ADBB41DE155BD00E2B98D /* TestUtil.h */,
+                               516ADBB51DE155BD00E2B98D /* TestUtil.m */,
+                               516ADBB61DE155BD00E2B98D /* WKWebViewAdditions.h */,
+                               516ADBB71DE155BD00E2B98D /* WKWebViewAdditions.m */,
+                               512D7AB51DE0FC590028F0E6 /* Resources */,
+                       );
+                       name = EditingHistory;
+                       path = EditingHistoryTest;
+                       sourceTree = "<group>";
+               };
+               512D7AB51DE0FC590028F0E6 /* Resources */ = {
+                       isa = PBXGroup;
+                       children = (
+                               51D3947E1DF2541D00ABE875 /* DumpEditingHistory.js */,
+                               51D3947F1DF2541D00ABE875 /* EditingHistoryUtil.js */,
+                               517FD93B1DE18DC900A73673 /* DOMTestingUtil.js */,
+                               516ADBA81DE155AB00E2B98D /* CaptureHarness.html */,
+                               516ADBAB1DE155AB00E2B98D /* PlaybackHarness.html */,
+                       );
+                       name = Resources;
+                       sourceTree = "<group>";
+               };
+               516ADBF11DE157AD00E2B98D /* EditingHistoryTests */ = {
+                       isa = PBXGroup;
+                       children = (
+                               516ADBF21DE157AD00E2B98D /* RewindAndPlaybackTests.m */,
+                               516ADBF41DE157AD00E2B98D /* Info.plist */,
+                       );
+                       path = EditingHistoryTests;
+                       sourceTree = "<group>";
+               };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+               516ADBC01DE155FC00E2B98D /* EditingHistory */ = {
+                       isa = PBXNativeTarget;
+                       buildConfigurationList = 516ADBDA1DE155FC00E2B98D /* Build configuration list for PBXNativeTarget "EditingHistory" */;
+                       buildPhases = (
+                               516ADBBD1DE155FC00E2B98D /* Sources */,
+                               516ADBBE1DE155FC00E2B98D /* Frameworks */,
+                               516ADBBF1DE155FC00E2B98D /* Resources */,
+                       );
+                       buildRules = (
+                       );
+                       dependencies = (
+                       );
+                       name = EditingHistory;
+                       productName = EditingHistory;
+                       productReference = 516ADBC11DE155FC00E2B98D /* EditingHistory.app */;
+                       productType = "com.apple.product-type.application";
+               };
+               516ADBEF1DE157AD00E2B98D /* EditingHistoryTests */ = {
+                       isa = PBXNativeTarget;
+                       buildConfigurationList = 516ADBF71DE157AD00E2B98D /* Build configuration list for PBXNativeTarget "EditingHistoryTests" */;
+                       buildPhases = (
+                               516ADBEC1DE157AD00E2B98D /* Sources */,
+                               516ADBED1DE157AD00E2B98D /* Frameworks */,
+                               516ADBEE1DE157AD00E2B98D /* Resources */,
+                       );
+                       buildRules = (
+                       );
+                       dependencies = (
+                               516ADBF61DE157AD00E2B98D /* PBXTargetDependency */,
+                       );
+                       name = EditingHistoryTests;
+                       productName = EditingHistoryTests;
+                       productReference = 516ADBF01DE157AD00E2B98D /* EditingHistoryTests.xctest */;
+                       productType = "com.apple.product-type.bundle.unit-test";
+               };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+               512D7A8B1DE0FBEF0028F0E6 /* Project object */ = {
+                       isa = PBXProject;
+                       attributes = {
+                               LastUpgradeCheck = 0820;
+                               TargetAttributes = {
+                                       516ADBC01DE155FC00E2B98D = {
+                                               CreatedOnToolsVersion = 8.2;
+                                               ProvisioningStyle = Automatic;
+                                       };
+                                       516ADBEF1DE157AD00E2B98D = {
+                                               CreatedOnToolsVersion = 8.2;
+                                               ProvisioningStyle = Automatic;
+                                               TestTargetID = 516ADBC01DE155FC00E2B98D;
+                                       };
+                               };
+                       };
+                       buildConfigurationList = 512D7A8E1DE0FBEF0028F0E6 /* Build configuration list for PBXProject "EditingHistory" */;
+                       compatibilityVersion = "Xcode 3.2";
+                       developmentRegion = English;
+                       hasScannedForEncodings = 0;
+                       knownRegions = (
+                               en,
+                               Base,
+                       );
+                       mainGroup = 512D7A8A1DE0FBEF0028F0E6;
+                       productRefGroup = 512D7A941DE0FBEF0028F0E6 /* Products */;
+                       projectDirPath = "";
+                       projectRoot = "";
+                       targets = (
+                               516ADBC01DE155FC00E2B98D /* EditingHistory */,
+                               516ADBEF1DE157AD00E2B98D /* EditingHistoryTests */,
+                       );
+               };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+               516ADBBF1DE155FC00E2B98D /* Resources */ = {
+                       isa = PBXResourcesBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                               51D394801DF2541D00ABE875 /* DumpEditingHistory.js in Resources */,
+                               516ADBE21DE156A900E2B98D /* CaptureHarness.html in Resources */,
+                               517FD93C1DE18DC900A73673 /* DOMTestingUtil.js in Resources */,
+                               516ADBE51DE156A900E2B98D /* PlaybackHarness.html in Resources */,
+                               51D394811DF2541D00ABE875 /* EditingHistoryUtil.js in Resources */,
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+               516ADBEE1DE157AD00E2B98D /* Resources */ = {
+                       isa = PBXResourcesBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+               516ADBBD1DE155FC00E2B98D /* Sources */ = {
+                       isa = PBXSourcesBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                               516ADBE61DE156BB00E2B98D /* main.m in Sources */,
+                               51ECC3EA1DEE33DD00CB267E /* TestRunner.m in Sources */,
+                               51ECC3E71DEE33CE00CB267E /* TestUtil.m in Sources */,
+                               51ECC3E91DEE33D200CB267E /* WKWebViewAdditions.m in Sources */,
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+               516ADBEC1DE157AD00E2B98D /* Sources */ = {
+                       isa = PBXSourcesBuildPhase;
+                       buildActionMask = 2147483647;
+                       files = (
+                               516ADBF31DE157AD00E2B98D /* RewindAndPlaybackTests.m in Sources */,
+                       );
+                       runOnlyForDeploymentPostprocessing = 0;
+               };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+               516ADBF61DE157AD00E2B98D /* PBXTargetDependency */ = {
+                       isa = PBXTargetDependency;
+                       target = 516ADBC01DE155FC00E2B98D /* EditingHistory */;
+                       targetProxy = 516ADBF51DE157AD00E2B98D /* PBXContainerItemProxy */;
+               };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+               512D7AA21DE0FBEF0028F0E6 /* Debug */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               ALWAYS_SEARCH_USER_PATHS = NO;
+                               CLANG_ANALYZER_NONNULL = YES;
+                               CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+                               CLANG_CXX_LIBRARY = "libc++";
+                               CLANG_ENABLE_MODULES = YES;
+                               CLANG_ENABLE_OBJC_ARC = YES;
+                               CLANG_WARN_BOOL_CONVERSION = YES;
+                               CLANG_WARN_CONSTANT_CONVERSION = YES;
+                               CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+                               CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+                               CLANG_WARN_EMPTY_BODY = YES;
+                               CLANG_WARN_ENUM_CONVERSION = YES;
+                               CLANG_WARN_INFINITE_RECURSION = YES;
+                               CLANG_WARN_INT_CONVERSION = YES;
+                               CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+                               CLANG_WARN_SUSPICIOUS_MOVE = YES;
+                               CLANG_WARN_UNREACHABLE_CODE = YES;
+                               CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+                               CODE_SIGN_IDENTITY = "-";
+                               COPY_PHASE_STRIP = NO;
+                               DEBUG_INFORMATION_FORMAT = dwarf;
+                               ENABLE_STRICT_OBJC_MSGSEND = YES;
+                               ENABLE_TESTABILITY = YES;
+                               GCC_C_LANGUAGE_STANDARD = gnu99;
+                               GCC_DYNAMIC_NO_PIC = NO;
+                               GCC_NO_COMMON_BLOCKS = YES;
+                               GCC_OPTIMIZATION_LEVEL = 0;
+                               GCC_PREPROCESSOR_DEFINITIONS = (
+                                       "DEBUG=1",
+                                       "$(inherited)",
+                               );
+                               GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+                               GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+                               GCC_WARN_UNDECLARED_SELECTOR = YES;
+                               GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+                               GCC_WARN_UNUSED_FUNCTION = YES;
+                               GCC_WARN_UNUSED_VARIABLE = YES;
+                               MACOSX_DEPLOYMENT_TARGET = 10.13;
+                               MTL_ENABLE_DEBUG_INFO = YES;
+                               ONLY_ACTIVE_ARCH = YES;
+                               SDKROOT = macosx;
+                       };
+                       name = Debug;
+               };
+               512D7AA31DE0FBEF0028F0E6 /* Release */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               ALWAYS_SEARCH_USER_PATHS = NO;
+                               CLANG_ANALYZER_NONNULL = YES;
+                               CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+                               CLANG_CXX_LIBRARY = "libc++";
+                               CLANG_ENABLE_MODULES = YES;
+                               CLANG_ENABLE_OBJC_ARC = YES;
+                               CLANG_WARN_BOOL_CONVERSION = YES;
+                               CLANG_WARN_CONSTANT_CONVERSION = YES;
+                               CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+                               CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+                               CLANG_WARN_EMPTY_BODY = YES;
+                               CLANG_WARN_ENUM_CONVERSION = YES;
+                               CLANG_WARN_INFINITE_RECURSION = YES;
+                               CLANG_WARN_INT_CONVERSION = YES;
+                               CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+                               CLANG_WARN_SUSPICIOUS_MOVE = YES;
+                               CLANG_WARN_UNREACHABLE_CODE = YES;
+                               CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+                               CODE_SIGN_IDENTITY = "-";
+                               COPY_PHASE_STRIP = NO;
+                               DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+                               ENABLE_NS_ASSERTIONS = NO;
+                               ENABLE_STRICT_OBJC_MSGSEND = YES;
+                               GCC_C_LANGUAGE_STANDARD = gnu99;
+                               GCC_NO_COMMON_BLOCKS = YES;
+                               GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+                               GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+                               GCC_WARN_UNDECLARED_SELECTOR = YES;
+                               GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+                               GCC_WARN_UNUSED_FUNCTION = YES;
+                               GCC_WARN_UNUSED_VARIABLE = YES;
+                               MACOSX_DEPLOYMENT_TARGET = 10.13;
+                               MTL_ENABLE_DEBUG_INFO = NO;
+                               SDKROOT = macosx;
+                       };
+                       name = Release;
+               };
+               516ADBDB1DE155FC00E2B98D /* Debug */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+                               COMBINE_HIDPI_IMAGES = YES;
+                               INFOPLIST_FILE = EditingHistory/Info.plist;
+                               LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
+                               PRODUCT_BUNDLE_IDENTIFIER = com.apple.EditingHistory;
+                               PRODUCT_NAME = "$(TARGET_NAME)";
+                               SDKROOT = macosx.internal;
+                       };
+                       name = Debug;
+               };
+               516ADBDC1DE155FC00E2B98D /* Release */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+                               COMBINE_HIDPI_IMAGES = YES;
+                               INFOPLIST_FILE = EditingHistory/Info.plist;
+                               LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
+                               PRODUCT_BUNDLE_IDENTIFIER = com.apple.EditingHistory;
+                               PRODUCT_NAME = "$(TARGET_NAME)";
+                               SDKROOT = macosx.internal;
+                       };
+                       name = Release;
+               };
+               516ADBF81DE157AD00E2B98D /* Debug */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               BUNDLE_LOADER = "$(TEST_HOST)";
+                               COMBINE_HIDPI_IMAGES = YES;
+                               GCC_PREPROCESSOR_DEFINITIONS = (
+                                       "DEBUG=1",
+                                       "$(inherited)",
+                                       "TEST=1",
+                               );
+                               INFOPLIST_FILE = EditingHistoryTests/Info.plist;
+                               LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
+                               PRODUCT_BUNDLE_IDENTIFIER = com.apple.EditingHistoryTests;
+                               PRODUCT_NAME = "$(TARGET_NAME)";
+                               TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EditingHistory.app/Contents/MacOS/EditingHistory";
+                       };
+                       name = Debug;
+               };
+               516ADBF91DE157AD00E2B98D /* Release */ = {
+                       isa = XCBuildConfiguration;
+                       buildSettings = {
+                               BUNDLE_LOADER = "$(TEST_HOST)";
+                               COMBINE_HIDPI_IMAGES = YES;
+                               GCC_PREPROCESSOR_DEFINITIONS = "TEST=1";
+                               INFOPLIST_FILE = EditingHistoryTests/Info.plist;
+                               LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
+                               PRODUCT_BUNDLE_IDENTIFIER = com.apple.EditingHistoryTests;
+                               PRODUCT_NAME = "$(TARGET_NAME)";
+                               TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EditingHistory.app/Contents/MacOS/EditingHistory";
+                       };
+                       name = Release;
+               };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+               512D7A8E1DE0FBEF0028F0E6 /* Build configuration list for PBXProject "EditingHistory" */ = {
+                       isa = XCConfigurationList;
+                       buildConfigurations = (
+                               512D7AA21DE0FBEF0028F0E6 /* Debug */,
+                               512D7AA31DE0FBEF0028F0E6 /* Release */,
+                       );
+                       defaultConfigurationIsVisible = 0;
+                       defaultConfigurationName = Release;
+               };
+               516ADBDA1DE155FC00E2B98D /* Build configuration list for PBXNativeTarget "EditingHistory" */ = {
+                       isa = XCConfigurationList;
+                       buildConfigurations = (
+                               516ADBDB1DE155FC00E2B98D /* Debug */,
+                               516ADBDC1DE155FC00E2B98D /* Release */,
+                       );
+                       defaultConfigurationIsVisible = 0;
+                       defaultConfigurationName = Release;
+               };
+               516ADBF71DE157AD00E2B98D /* Build configuration list for PBXNativeTarget "EditingHistoryTests" */ = {
+                       isa = XCConfigurationList;
+                       buildConfigurations = (
+                               516ADBF81DE157AD00E2B98D /* Debug */,
+                               516ADBF91DE157AD00E2B98D /* Release */,
+                       );
+                       defaultConfigurationIsVisible = 0;
+                       defaultConfigurationName = Release;
+               };
+/* End XCConfigurationList section */
+       };
+       rootObject = 512D7A8B1DE0FBEF0028F0E6 /* Project object */;
+}
diff --git a/Tools/EditingHistory/EditingHistory/Info.plist b/Tools/EditingHistory/EditingHistory/Info.plist
new file mode 100644 (file)
index 0000000..8a4ebbf
--- /dev/null
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+       <key>CFBundleDevelopmentRegion</key>
+       <string>en</string>
+       <key>CFBundleExecutable</key>
+       <string>$(EXECUTABLE_NAME)</string>
+       <key>CFBundleIconFile</key>
+       <string></string>
+       <key>CFBundleIdentifier</key>
+       <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+       <key>CFBundleInfoDictionaryVersion</key>
+       <string>6.0</string>
+       <key>CFBundleName</key>
+       <string>$(PRODUCT_NAME)</string>
+       <key>CFBundlePackageType</key>
+       <string>APPL</string>
+       <key>CFBundleShortVersionString</key>
+       <string>1.0</string>
+       <key>CFBundleVersion</key>
+       <string>1</string>
+       <key>LSMinimumSystemVersion</key>
+       <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
+       <key>NSPrincipalClass</key>
+       <string>NSApplication</string>
+</dict>
+</plist>
diff --git a/Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html b/Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html
new file mode 100644 (file)
index 0000000..0a0228c
--- /dev/null
@@ -0,0 +1,14 @@
+<html>
+<head>
+<script src="DOMTestingUtil.js"></script>
+<script src="EditingHistoryUtil.js"></script>
+<script>
+window.addEventListener("load", () => {
+    let scriptElement = document.createElement("script");
+    scriptElement.src = "DumpEditingHistory.js";
+    scriptElement.addEventListener("load", () => document.body.focus());
+    document.head.appendChild(scriptElement);
+});
+</script>
+</head>
+</html>
diff --git a/Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js b/Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js
new file mode 100644 (file)
index 0000000..c2c9934
--- /dev/null
@@ -0,0 +1,24 @@
+(() => {
+    function subtreeAsString(node, currentDepth = 0) {
+        let childNodesAsStrings = Array.from(node.childNodes).map(child => subtreeAsString(child, currentDepth + 1));
+        let nodeAsString = node.nodeName;
+        if (node.nodeType == Node.ELEMENT_NODE) {
+            nodeAsString = `<${nodeAsString}>`
+            let attributeDescriptions = [];
+            for (let i = 0; i < node.attributes.length; i++) {
+                let attribute = node.attributes.item(i);
+                attributeDescriptions.push(`${attribute.localName}=${attribute.value}`);
+            }
+            nodeAsString += `: ${attributeDescriptions.join(", ")}`;
+        }
+
+        if (node.nodeType == Node.TEXT_NODE)
+            nodeAsString += `: '${node.textContent.replace(/\s/g, " ")}'`;
+
+        return `${"    ".repeat(currentDepth)}${nodeAsString}\n${childNodesAsStrings.join("\n")}`
+    }
+
+    window.DOMUtil = {
+        subtreeAsString
+    };
+})();
diff --git a/Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html b/Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html
new file mode 100644 (file)
index 0000000..5c6fe03
--- /dev/null
@@ -0,0 +1,422 @@
+<html>
+<head>
+<style>
+body {
+    margin: 8px;
+}
+
+#overlay {
+    width: 300px;
+    height: calc(100% - 32px);
+    position: fixed;
+    right: 16px;
+    top: 16px;
+    background-color: rgba(255, 255, 255, 0.75);
+    transition: 0.25s ease-in-out;
+}
+
+#updateInfoPanel {
+    height: calc(90% - 30px);
+    overflow: scroll;
+    white-space: nowrap;
+    padding: 10px;
+}
+
+#controls {
+    height: 10%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+}
+
+#controls-wrapper {
+    margin: 0 auto;
+}
+
+summary:focus {
+    outline: none;
+}
+
+details {
+    padding: 4px 0;
+}
+
+#updateMarker {
+    width: 80%;
+    margin: 20px 0;
+    border-top: 1px red dashed;
+}
+
+.eh-node {
+    margin: 0 2px;
+    padding: 0 4px;
+    background-color: rgba(59, 131, 238, 0.25);
+    border-radius: 4px;
+    cursor: default;
+}
+
+.eh-node:hover {
+    background-color: rgba(59, 131, 238, 0.5);
+}
+
+.node-highlight {
+    position: absolute;
+    background-color: rgba(59, 131, 238, 0.05);
+    border: 1px solid rgb(59, 131, 238);
+    border-radius: 2px;
+    z-index: -1;
+}
+
+li {
+    line-height: 1.5;
+}
+
+summary {
+    margin-top: 0;
+}
+
+#dropzone {
+    margin: 100px;
+    padding: 50px;
+    width: calc(100% - 300px);
+    height: calc(100% - 300px);
+    border: 15px #E8E8E8 dashed;
+    display: flex;
+    align-items: center;
+    text-align: center;
+    cursor: pointer;
+}
+
+a:visited, a:link {
+    text-decoration: none;
+    color: red;
+}
+
+#toggleOverlayButton {
+    margin-top: 10px;
+}
+
+#upload {
+    opacity: 0;
+}
+
+#dropMessage {
+    font-size: 50px;
+    color: #E8E8E8;
+    margin: 0 auto;
+    pointer-events: none;
+    font-family: -apple-system;
+}
+</style>
+<script src="DOMTestingUtil.js"></script>
+<script src="EditingHistoryUtil.js"></script>
+<script>
+class DOMUpdateHistoryContext {
+    constructor(nodeMap, updates) {
+        this._nodeMap = nodeMap;
+        this._updates = updates;
+        this._currentUpdateIndex = updates.length;
+    }
+
+    currentIndex() {
+        return this._currentUpdateIndex;
+    }
+
+    updates() {
+        return this._updates;
+    }
+
+    updateAt(index) {
+        if (index < 0 || index >= this._updates.length)
+            return null;
+
+        return this._updates[index];
+    }
+
+    selectionStateAt(index) {
+        let beforeUpdate = this.updateAt(index - 1);
+        let afterUpdate = this.updateAt(index);
+        if (beforeUpdate instanceof EditingHistory.SelectionUpdate)
+            return beforeUpdate.state;
+        if (afterUpdate instanceof EditingHistory.SelectionUpdate)
+            return afterUpdate.state;
+        return null;
+    }
+
+    applyCurrentSelectionState(selection) {
+        let selectionState = this.selectionStateAt(this._currentUpdateIndex);
+        if (selectionState && selection)
+            selectionState.applyToSelection(selection);
+        else
+            selection.removeAllRanges();
+    }
+
+    next() {
+        if (this._currentUpdateIndex >= this._updates.length)
+            return;
+
+        this._updates[this._currentUpdateIndex].apply();
+        this._currentUpdateIndex++;
+    }
+
+    previous() {
+        if (this._currentUpdateIndex <= 0)
+            return;
+
+        this._updates[this._currentUpdateIndex - 1].unapply();
+        this._currentUpdateIndex--;
+    }
+
+    jumpTo(index) {
+        index = Math.max(Math.min(index, this._updates.length), 0);
+        while(this._currentUpdateIndex != index) {
+            if (this._currentUpdateIndex < index)
+                this.next();
+            else
+                this.previous();
+        }
+    }
+}
+
+window.onload = () => {
+    function setupEditingHistory(jsonData, withControls=true) {
+        let parsedResult = JSON.parse(jsonData);
+        let globalNodeMap = EditingHistory.GlobalNodeMap.fromObject(parsedResult.globalNodeMap);
+        let updates = parsedResult.updates.map(updateInfo => EditingHistory.DOMUpdate.ofType(updateInfo.type).fromObject(updateInfo, globalNodeMap));
+        EditingHistory.context = new DOMUpdateHistoryContext(globalNodeMap, updates);
+
+        function detailsElementAtIndex(index) {
+            return document.querySelector(`#updateInfo-${index}`);
+        }
+
+        function updateOverlay() {
+            let currentIndex = EditingHistory.context.currentIndex();
+            let numberOfUpdates = EditingHistory.context.updates().length;
+            progressLabel.textContent = `${currentIndex}/${numberOfUpdates}`;
+            previousButton.disabled = currentIndex <= 0;
+            nextButton.disabled = currentIndex >= numberOfUpdates;
+            updateMarker.remove();
+            if (0 <= currentIndex && currentIndex <= numberOfUpdates) {
+                let currentUpdateDetails = detailsElementAtIndex(currentIndex);
+                updateInfoPanel.insertBefore(updateMarker, currentUpdateDetails);
+                if (updateMarker.offsetTop < updateInfoPanel.scrollTop || updateInfoPanel.scrollTop + updateInfoPanel.clientHeight < updateMarker.offsetTop)
+                updateMarker.scrollIntoView();
+            }
+        }
+
+        function openAllDetailsUnderElement(element) {
+            for (let child of Array.from(element.children)) {
+                if (child.tagName === "DETAILS")
+                    child.open = true;
+
+                openAllDetailsUnderElement(child);
+            }
+        }
+
+        upload.remove();
+        dropzone.remove();
+        for (let node of globalNodeMap.nodes()) {
+            if (node.tagName === "BODY") {
+                document.body = node;
+                break;
+            }
+        }
+
+        if (!withControls)
+            return;
+
+        let overlay = document.createElement("div");
+        overlay.id = "overlay";
+        overlay.innerHTML =
+        `<code>
+            <div id="information">
+                <div id="updateInfoPanel"></div>
+            </div>
+            <div id="controls">
+                <div>
+                    <button id="expandButton"><code>Show all</code></button><button id="collapseButton"><code>Hide all</code></button>
+                </div>
+                <div>
+                    <button disabled id="previousButton">&lt;</button><button disabled id="nextButton">&gt;</button>
+                </div>
+                <div id="progressLabel">-/-</div>
+                <div>
+                    <button id="toggleOverlayButton">Toggle overlay</button>
+                </div>
+            </div>
+        </code>`;
+        document.body.appendChild(overlay);
+        updates.forEach((update, index) => {
+            let detailsElement = update.detailsElement();
+            let summary = detailsElement.children[0];
+            let indexElement = document.createElement("span");
+            indexElement.innerHTML = `[<a href=#>${index}</a>] `;
+            indexElement.children[0].addEventListener("click", () => {
+                EditingHistory.context.jumpTo(index + 1);
+                EditingHistory.context.applyCurrentSelectionState(getSelection());
+                detailsElement.open = true;
+                updateOverlay();
+            });
+            summary.insertBefore(indexElement, summary.childNodes[0]);
+            detailsElement.id = `updateInfo-${index}`;
+            detailsElement.classList.add("updateInfo");
+            detailsElement.addEventListener("click", (event) => {
+                if (event.altKey && !detailsElement.open)
+                    openAllDetailsUnderElement(detailsElement);
+
+                EditingHistory.context.applyCurrentSelectionState(getSelection());
+            });
+            updateInfoPanel.append(detailsElement);
+        });
+        let updateMarker = document.createElement("div");
+        updateMarker.id = "updateMarker";
+        updateInfoPanel.append(updateMarker);
+
+        nextButton.addEventListener("click", () => {
+            EditingHistory.context.next();
+            EditingHistory.context.applyCurrentSelectionState(getSelection());
+            updateOverlay();
+        });
+
+        previousButton.addEventListener("click", () => {
+            EditingHistory.context.previous();
+            EditingHistory.context.applyCurrentSelectionState(getSelection());
+            updateOverlay();
+        });
+
+        let isOverlayExpanded = false;
+        toggleOverlayButton.addEventListener("click", () => {
+            if (isOverlayExpanded) {
+                overlay.style.width = "300px";
+                toggleOverlayButton.value = "Expand overlay";
+            } else {
+                overlay.style.width = "50%";
+                toggleOverlayButton.value = "Collapse overlay";
+            }
+            isOverlayExpanded = !isOverlayExpanded;
+        });
+
+        document.addEventListener("keydown", event => {
+            if (event.key === "ArrowRight" || event.key === "ArrowDown") {
+                removeAllHighlights();
+                EditingHistory.context.next();
+                EditingHistory.context.applyCurrentSelectionState(getSelection());
+                event.preventDefault();
+                updateOverlay();
+            } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+                removeAllHighlights();
+                EditingHistory.context.previous();
+                EditingHistory.context.applyCurrentSelectionState(getSelection());
+                event.preventDefault();
+                updateOverlay();
+            }
+        });
+
+        expandButton.addEventListener("click", () => {
+            document.querySelectorAll(".updateInfo").forEach(details => details.open = true);
+            updateOverlay();
+        });
+
+        collapseButton.addEventListener("click", () => {
+            document.querySelectorAll(".updateInfo").forEach(details => details.open = false);
+            updateOverlay();
+        });
+
+        ["selectstart", "dragenter", "dragover", "drop", "beforeinput"].forEach((type) => {
+            document.addEventListener(type, event => event.preventDefault());
+        });
+
+        document.querySelectorAll(".eh-node").forEach((node) => {
+            let guid = parseInt(node.getAttribute("eh-guid"));
+            if (isNaN(guid))
+                return;
+
+            let targetNode = globalNodeMap.nodeForGUID(guid);
+            node.addEventListener("click", () => console.log(targetNode));
+            node.addEventListener("mouseenter", () => showHighlightOverNode(targetNode));
+            node.addEventListener("mouseleave", removeAllHighlights);
+        });
+
+        updateOverlay();
+        EditingHistory.context.applyCurrentSelectionState(getSelection());
+    }
+
+    function showHighlightOverNode(node) {
+        if (!document.body.contains(node))
+            return;
+
+        if (node.nodeType === Node.ELEMENT_NODE) {
+            showHighlightOverBoundingRect(node.getBoundingClientRect());
+            return;
+        }
+
+        if (node.nodeType === Node.TEXT_NODE) {
+            let range = document.createRange();
+            range.selectNodeContents(node);
+            showHighlightOverBoundingRect(range.getBoundingClientRect());
+        }
+    }
+
+    function showHighlightOverBoundingRect(bounds) {
+        let highlight = document.createElement("div");
+        highlight.classList.add("node-highlight");
+        highlight.style.left = bounds.left - 2;
+        highlight.style.top = bounds.top - 2;
+        highlight.style.width = bounds.width + 3;
+        highlight.style.height = bounds.height + 3;
+        document.body.appendChild(highlight);
+    }
+
+    function removeAllHighlights() {
+        document.querySelectorAll(".node-highlight").forEach(highlight => highlight.remove());
+    }
+
+    dropzone.addEventListener("mouseenter", emphasizeDrop);
+    dropzone.addEventListener("mouseleave", unemphasizeDrop);
+    dropzone.addEventListener("dragenter", emphasizeDrop);
+    dropzone.addEventListener("dragleave", unemphasizeDrop);
+    dropzone.addEventListener("dragover", event => event.preventDefault());
+    dropzone.addEventListener("click", () => upload.click());
+    dropzone.addEventListener("drop", dropEvent => {
+        dropEvent.preventDefault();
+        fileSelected(dropEvent.dataTransfer.files);
+    });
+
+    upload.files = null;
+    EditingHistory.setupEditingHistory = setupEditingHistory;
+};
+
+function emphasizeDrop(event) {
+    dropzone.style.border = "15px #D8D8D8 dashed";
+    dropMessage.style.color = "#D8D8D8";
+    event.preventDefault();
+}
+
+function unemphasizeDrop(event) {
+    dropzone.style.border = "15px #E8E8E8 dashed";
+    dropMessage.style.color = "#E8E8E8";
+    event.preventDefault();
+}
+
+function fileSelected(files) {
+    dropzone.removeEventListener("mouseenter", emphasizeDrop);
+    dropzone.removeEventListener("mouseleave", unemphasizeDrop);
+    dropzone.removeEventListener("dragenter", emphasizeDrop);
+    dropzone.removeEventListener("dragleave", unemphasizeDrop);
+
+    console.log(`Selected ${files.length} file(s).`);
+
+    let reader = new FileReader();
+    reader.onload = event => EditingHistory.setupEditingHistory(event.target.result);
+    reader.readAsText(files[0]);
+}
+</script>
+</head>
+<body>
+    <div id="dropzone">
+        <div id="dropMessage">Drop an editing record here</div>
+    </div>
+    <input id="upload" onchange=fileSelected(files) type="file"></input>
+</body>
+</html>
diff --git a/Tools/EditingHistory/EditingHistory/TestRunner.h b/Tools/EditingHistory/EditingHistory/TestRunner.h
new file mode 100644 (file)
index 0000000..00dced1
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import <AppKit/AppKit.h>
+#import <Foundation/Foundation.h>
+#import <WebKit/WebKit.h>
+
+@interface TestRunner : NSObject
+
+@property (nonatomic, readonly) NSWindow *window;
+@property (nonatomic, readonly) WKWebView *webView;
+
+- (void)expectEvents:(NSDictionary<NSString *, NSNumber *> *)expectedEventCounts afterPerforming:(dispatch_block_t)action;
+- (void)loadCaptureTestHarness;
+- (void)loadPlaybackTestHarnessWithJSON:(NSString *)json;
+- (void)setTextObfuscationEnabled:(BOOL)enabled;
+- (void)typeString:(NSString *)string;
+- (void)jumpToUpdateIndex:(NSInteger)index;
+- (void)deleteBackwards:(NSInteger)times;
+@property (nonatomic, readonly) NSString *editingHistoryJSON;
+@property (nonatomic, readonly) NSString *bodyElementSubtree;
+@property (nonatomic, readonly) NSString *bodyTextContent;
+@property (nonatomic, readonly) NSInteger numberOfUpdates;
+
+@end
diff --git a/Tools/EditingHistory/EditingHistory/TestRunner.m b/Tools/EditingHistory/EditingHistory/TestRunner.m
new file mode 100644 (file)
index 0000000..631bf47
--- /dev/null
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import "config.h"
+#import "TestRunner.h"
+
+#import "TestUtil.h"
+#import "WKWebViewAdditions.h"
+#import <Carbon/Carbon.h>
+
+static WKUserScript *injectedMessageEventHandlerScript(NSString *listener, NSString *event)
+{
+    NSString *source = [NSString stringWithFormat:@"%@.addEventListener('%@', () => {"
+        "setTimeout(() => webkit.messageHandlers.eventHandler.postMessage('%@'), 0);"
+        "});", listener, event, event];
+    return [[WKUserScript alloc] initWithSource:source injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
+}
+
+@interface TestRunner () <WKScriptMessageHandler>
+
+@property (nonatomic) NSMutableDictionary *pendingEventCounts;
+
+@end
+
+@implementation TestRunner
+
+- (instancetype)init
+{
+    NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
+    [window setFrameOrigin:NSMakePoint(0, 0)];
+    [window setIsVisible:YES];
+
+    WKWebView *webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600)];
+    [[window contentView] addSubview:webView];
+    [window makeKeyAndOrderFront:webView];
+    [webView becomeFirstResponder];
+
+    _window = window;
+    _webView = webView;
+    _pendingEventCounts = nil;
+
+    WKUserContentController *contentController = webView.configuration.userContentController;
+    [contentController addScriptMessageHandler:self name:@"eventHandler"];
+    [contentController addUserScript:injectedMessageEventHandlerScript(@"document.body", @"focus")];
+    [contentController addUserScript:injectedMessageEventHandlerScript(@"document", @"input")];
+    [contentController addUserScript:injectedMessageEventHandlerScript(@"document", @"selectionchange")];
+
+    return self;
+}
+
+- (void)deleteBackwards:(NSInteger)times
+{
+    for (NSInteger i = 0; i < times; i++) {
+        [self expectEvents:@{ @"input": @1, @"selectionchange": @1 } afterPerforming:^() {
+            [_webView keyPressWithCharacters:nil keyCode:KeyCodeTypeDeleteBackward];
+        }];
+    }
+}
+
+- (void)typeString:(NSString *)string
+{
+    for (NSInteger charIndex = 0; charIndex < string.length; charIndex++) {
+        [self expectEvents:@{ @"input": @1, @"selectionchange": @1 } afterPerforming:^() {
+            [_webView typeCharacter:[string characterAtIndex:charIndex]];
+        }];
+    }
+}
+
+- (NSString *)bodyElementSubtree
+{
+    return [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"DOMUtil.subtreeAsString(document.body)"]];
+}
+
+- (NSString *)bodyTextContent
+{
+    return [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"document.body.textContent"]];
+}
+
+- (NSString *)editingHistoryJSON
+{
+    return [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"EditingHistory.getEditingHistoryAsJSONString(false)"]];
+}
+
+- (void)loadPlaybackTestHarnessWithJSON:(NSString *)json
+{
+    [_webView loadPageFromBundleNamed:@"PlaybackHarness"];
+    waitUntil(CONDITION_BLOCK([[_webView stringByEvaluatingJavaScriptFromString:@"!!window.EditingHistory && !!EditingHistory.setupEditingHistory"] boolValue]));
+
+    json = [[json stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] stringByReplacingOccurrencesOfString:@"`" withString:@"\\`"];
+    [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"EditingHistory.setupEditingHistory(`%@`, false)", json]];
+}
+
+- (NSInteger)numberOfUpdates {
+    return [[_webView stringByEvaluatingJavaScriptFromString:@"EditingHistory.context.updates().length"] integerValue];
+}
+
+- (void)jumpToUpdateIndex:(NSInteger)index
+{
+    [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"EditingHistory.context.jumpTo(%tu)", index]];
+}
+
+- (void)expectEvents:(NSDictionary<NSString *, NSNumber *> *)expectedEventCounts afterPerforming:(dispatch_block_t)action
+{
+    _pendingEventCounts = [expectedEventCounts mutableCopy];
+    dispatch_async(dispatch_get_main_queue(), action);
+    waitUntil(CONDITION_BLOCK(self.isDoneWaitingForPendingEvents));
+    _pendingEventCounts = nil;
+}
+
+- (void)loadCaptureTestHarness
+{
+    [self expectEvents:@{ @"focus" : @1 } afterPerforming:^() {
+        [_webView loadPageFromBundleNamed:@"CaptureHarness"];
+    }];
+}
+
+- (void)setTextObfuscationEnabled:(BOOL)enabled
+{
+    [_webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"EditingHistory.Obfuscator.shared().enabled = %s", enabled ? "true" : "false"]];
+}
+
+- (BOOL)isDoneWaitingForPendingEvents
+{
+    NSInteger numberOfPendingEvents = 0;
+    for (NSNumber *count in _pendingEventCounts.allValues)
+        numberOfPendingEvents += [count integerValue];
+    return _pendingEventCounts && !numberOfPendingEvents;
+}
+
+- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
+{
+    if ([message.name isEqualToString:@"eventHandler"] && _pendingEventCounts) {
+        NSString *eventType = message.body;
+        _pendingEventCounts[eventType] = @(MAX(0, [_pendingEventCounts[eventType] integerValue] - 1));
+    }
+}
+
+@end
diff --git a/Tools/EditingHistory/EditingHistory/TestUtil.h b/Tools/EditingHistory/EditingHistory/TestUtil.h
new file mode 100644 (file)
index 0000000..3d4c313
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#pragma once
+
+#import <Foundation/Foundation.h>
+
+// Some named key codes, mainly for convenience.
+enum KeyCodeType {
+    KeyCodeTypeDeleteBackward = 0x33,
+    KeyCodeTypeLeftArrow = 0x7B,
+    KeyCodeTypeRightArrow = 0x7C
+};
+
+/**
+ * Return YES when the condition is done and the program should stop spinning.
+ */
+typedef BOOL (^ConditionBlock)();
+#define CONDITION_BLOCK(expr) ^BOOL() { return expr; }
+
+/**
+ * Spins a runloop until the given condition block evaluates to YES. If the given timeout interval has elapsed, and the condition is still unsatisfied,
+ * stop spinning and return NO. Otherwise, return YES.
+ */
+BOOL waitUntilWithTimeout(ConditionBlock, NSTimeInterval);
+BOOL waitUntil(ConditionBlock);
diff --git a/Tools/EditingHistory/EditingHistory/TestUtil.m b/Tools/EditingHistory/EditingHistory/TestUtil.m
new file mode 100644 (file)
index 0000000..8d93e6d
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import "config.h"
+#import "TestUtil.h"
+
+const NSTimeInterval DefaultTimeoutInterval = 10;
+
+BOOL waitUntilWithTimeout(ConditionBlock condition, NSTimeInterval timeout)
+{
+    NSTimeInterval startTime = [NSDate timeIntervalSinceReferenceDate];
+    while ([[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantPast]] && [NSDate timeIntervalSinceReferenceDate] - startTime < timeout) {
+        if (condition())
+            return YES;
+    }
+    return NO;
+}
+
+BOOL waitUntil(ConditionBlock condition)
+{
+    return waitUntilWithTimeout(condition, DefaultTimeoutInterval);
+}
diff --git a/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h b/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h
new file mode 100644 (file)
index 0000000..dfd3833
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import <WebKit/WebKit.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface WKWebView (EditingHistoryTest)
+
+- (void)loadPageFromBundleNamed:(NSString *)name;
+- (void)typeCharacter:(char)character;
+- (void)keyPressWithCharacters:(nullable NSString *)characters keyCode:(char)keyCode;
+- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m b/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m
new file mode 100644 (file)
index 0000000..497930d
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import "config.h"
+#import "WKWebViewAdditions.h"
+
+#import "TestUtil.h"
+
+#import <Carbon/Carbon.h>
+
+@implementation WKWebView (EditingHistoryTest)
+
+- (void)loadPageFromBundleNamed:(NSString *)name
+{
+    NSURL *pathToPage = [[NSBundle mainBundle] URLForResource:name withExtension:@"html" subdirectory:nil];
+    [self loadRequest:[NSURLRequest requestWithURL:pathToPage]];
+}
+
+- (void)typeCharacter:(char)character
+{
+    [self keyPressWithCharacters:[NSString stringWithFormat:@"%c", character] keyCode:character];
+}
+
+- (void)keyPressWithCharacters:(nullable NSString *)characters keyCode:(char)keyCode
+{
+    NSEventType keyDownEventType = NSEventTypeKeyDown;
+    NSEventType keyUpEventType = NSEventTypeKeyUp;
+    [self keyDown:[NSEvent keyEventWithType:keyDownEventType location:NSZeroPoint modifierFlags:0 timestamp:GetCurrentEventTime() windowNumber:self.window.windowNumber context:nil characters:characters charactersIgnoringModifiers:characters isARepeat:NO keyCode:keyCode]];
+    [self keyUp:[NSEvent keyEventWithType:keyUpEventType location:NSZeroPoint modifierFlags:0 timestamp:GetCurrentEventTime() windowNumber:self.window.windowNumber context:nil characters:characters charactersIgnoringModifiers:characters isARepeat:NO keyCode:keyCode]];
+}
+
+- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script
+{
+    __block bool doneEvaluatingJavaScript = false;
+    __block NSString *returnResult = nil;
+    [self evaluateJavaScript:script completionHandler:^(id returnValue, NSError *error) {
+        if (error)
+            NSLog(@"Error evaluating JavaScript: %@", error);
+
+        returnResult = error || !returnValue ? nil : [NSString stringWithFormat:@"%@", returnValue];
+        doneEvaluatingJavaScript = true;
+    }];
+    waitUntil(CONDITION_BLOCK(doneEvaluatingJavaScript));
+    return returnResult;
+}
+
+@end
diff --git a/Tools/EditingHistory/EditingHistory/main.m b/Tools/EditingHistory/EditingHistory/main.m
new file mode 100644 (file)
index 0000000..57e8485
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import <AppKit/AppKit.h>
+#import <Cocoa/Cocoa.h>
+
+int main(int argc, const char * argv[])
+{
+    [[NSWorkspace sharedWorkspace] openFile:[[NSBundle mainBundle] pathForResource:@"PlaybackHarness" ofType:@"html"] withApplication:@"Safari"];
+
+    return NSApplicationMain(argc, argv);
+}
diff --git a/Tools/EditingHistory/EditingHistoryTests/Info.plist b/Tools/EditingHistory/EditingHistoryTests/Info.plist
new file mode 100644 (file)
index 0000000..6c6c23c
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+       <key>CFBundleDevelopmentRegion</key>
+       <string>en</string>
+       <key>CFBundleExecutable</key>
+       <string>$(EXECUTABLE_NAME)</string>
+       <key>CFBundleIdentifier</key>
+       <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+       <key>CFBundleInfoDictionaryVersion</key>
+       <string>6.0</string>
+       <key>CFBundleName</key>
+       <string>$(PRODUCT_NAME)</string>
+       <key>CFBundlePackageType</key>
+       <string>BNDL</string>
+       <key>CFBundleShortVersionString</key>
+       <string>1.0</string>
+       <key>CFBundleVersion</key>
+       <string>1</string>
+</dict>
+</plist>
diff --git a/Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m b/Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m
new file mode 100644 (file)
index 0000000..2cbf48a
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#import "TestRunner.h"
+#import "TestUtil.h"
+#import "WKWebViewAdditions.h"
+#import <XCTest/XCTest.h>
+
+@interface RewindAndPlaybackTests : XCTestCase
+
+@end
+
+@implementation RewindAndPlaybackTests {
+    TestRunner *testRunner;
+}
+
+- (void)setUp
+{
+    testRunner = [[TestRunner alloc] init];
+    [testRunner loadCaptureTestHarness];
+    [testRunner setTextObfuscationEnabled:NO];
+}
+
+- (void)tearDown
+{
+    testRunner = nil;
+}
+
+- (void)testTypingSingleLineOfText
+{
+    [testRunner typeString:@"hello world"];
+    NSString *originalSubtree = testRunner.bodyElementSubtree;
+
+    [self rewindAndPlaybackEditingInPlaybackTestHarness];
+    XCTAssertTrue([self originalBodySubtree:originalSubtree isEqualToFinalSubtree:testRunner.bodyElementSubtree]);
+    XCTAssertEqualObjects(testRunner.bodyTextContent, @"hello world");
+}
+
+- (void)testTypingMultipleLinesOfText
+{
+    [testRunner typeString:@"foo"];
+    [testRunner typeString:@"\n"];
+    [testRunner typeString:@"bar"];
+    NSString *originalSubtree = testRunner.bodyElementSubtree;
+
+    [self rewindAndPlaybackEditingInPlaybackTestHarness];
+    XCTAssertTrue([self originalBodySubtree:originalSubtree isEqualToFinalSubtree:testRunner.bodyElementSubtree]);
+    XCTAssertEqualObjects(testRunner.bodyTextContent, @"foobar");
+}
+
+- (void)testTypingAndDeletingText
+{
+    [testRunner typeString:@"apple"];
+    [testRunner deleteBackwards:3];
+
+    NSString *originalSubtree = testRunner.bodyElementSubtree;
+
+    [self rewindAndPlaybackEditingInPlaybackTestHarness];
+    XCTAssertTrue([self originalBodySubtree:originalSubtree isEqualToFinalSubtree:testRunner.bodyElementSubtree]);
+    XCTAssertEqualObjects(testRunner.bodyTextContent, @"ap");
+}
+
+- (void)rewindAndPlaybackEditingInPlaybackTestHarness
+{
+    // This assumes that the test runner has loaded the capture test harness.
+    [testRunner loadPlaybackTestHarnessWithJSON:testRunner.editingHistoryJSON];
+
+    // Rewind to the very beginning, then play back all editing again.
+    [testRunner jumpToUpdateIndex:0];
+    [testRunner jumpToUpdateIndex:testRunner.numberOfUpdates];
+}
+
+- (BOOL)originalBodySubtree:(NSString *)originalSubtree isEqualToFinalSubtree:(NSString *)finalSubtree
+{
+    if ([originalSubtree isEqualToString:finalSubtree])
+        return YES;
+
+    NSLog(@">>>>>>>");
+    NSLog(@"The original subtree is:\n%@", originalSubtree);
+    NSLog(@"=======");
+    NSLog(@"The final subtree is:\n%@", finalSubtree);
+    NSLog(@"<<<<<<<");
+
+    return NO;
+}
+
+@end