Diff
Modified: trunk/Source/WebCore/ChangeLog (209469 => 209470)
--- trunk/Source/WebCore/ChangeLog 2016-12-07 20:32:39 UTC (rev 209469)
+++ trunk/Source/WebCore/ChangeLog 2016-12-07 20:40:43 UTC (rev 209470)
@@ -1,3 +1,127 @@
+2016-12-07 Wenson Hsieh <wenson_hs...@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.no...@apple.com>
ASSERT crash while running media-source/mediasource-activesourcebuffers.html under Stress GC bot.
Added: trunk/Source/WebCore/InternalScripts/DumpEditingHistory.js (0 => 209470)
--- trunk/Source/WebCore/InternalScripts/DumpEditingHistory.js (rev 0)
+++ trunk/Source/WebCore/InternalScripts/DumpEditingHistory.js 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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,
+ });
+ });
+})();
Added: trunk/Source/WebCore/InternalScripts/EditingHistoryUtil.js (0 => 209470)
--- trunk/Source/WebCore/InternalScripts/EditingHistoryUtil.js (rev 0)
+++ trunk/Source/WebCore/InternalScripts/EditingHistoryUtil.js 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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 = ""
+ 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 = ""
+ }
+
+ unapply() {
+ this.targetNode().data = ""
+ }
+
+ 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 = ""
+ 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
+ };
+})();
Modified: trunk/Source/WebCore/WebCore.xcodeproj/project.pbxproj (209469 => 209470)
--- trunk/Source/WebCore/WebCore.xcodeproj/project.pbxproj 2016-12-07 20:32:39 UTC (rev 209469)
+++ trunk/Source/WebCore/WebCore.xcodeproj/project.pbxproj 2016-12-07 20:40:43 UTC (rev 209470)
@@ -2249,6 +2249,8 @@
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, ); }; };
@@ -6949,6 +6951,18 @@
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;
@@ -9444,6 +9458,8 @@
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>"; };
@@ -15386,6 +15402,7 @@
2E4346310F546A6800B0F1BA /* workers */,
E1F0424309839389006694EA /* xml */,
656580EC09D12B20000E61D7 /* Derived Sources */,
+ 51D394731DF244BA00ABE875 /* InternalScripts */,
089C1665FE841158C02AAC07 /* Resources */,
3717D7E417ECC36C003C276D /* Scripts */,
0867D69AFE84028FC02AAC07 /* Frameworks */,
@@ -17279,6 +17296,15 @@
path = mac;
sourceTree = "<group>";
};
+ 51D394731DF244BA00ABE875 /* InternalScripts */ = {
+ isa = PBXGroup;
+ children = (
+ 51D394741DF2454000ABE875 /* DumpEditingHistory.js */,
+ 51D394751DF2454000ABE875 /* EditingHistoryUtil.js */,
+ );
+ name = InternalScripts;
+ sourceTree = "<group>";
+ };
59B5977111086556007159E8 /* jsc */ = {
isa = PBXGroup;
children = (
@@ -28383,6 +28409,7 @@
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 = (
);
Modified: trunk/Tools/ChangeLog (209469 => 209470)
--- trunk/Tools/ChangeLog 2016-12-07 20:32:39 UTC (rev 209469)
+++ trunk/Tools/ChangeLog 2016-12-07 20:40:43 UTC (rev 209470)
@@ -1,3 +1,80 @@
+2016-12-07 Wenson Hsieh <wenson_hs...@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 <pnorm...@igalia.com>
[GTK][jhbuild] missing dependency on libvpx in gst-plugins-good
Added: trunk/Tools/EditingHistory/EditingHistory/Info.plist (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/Info.plist (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/Info.plist 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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>
Added: trunk/Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/Resources/CaptureHarness.html 2016-12-07 20:40:43 UTC (rev 209470)
@@ -0,0 +1,14 @@
+<html>
+<head>
+<script src=""
+<script src=""
+<script>
+window.addEventListener("load", () => {
+ let scriptElement = document.createElement("script");
+ scriptElement.src = ""
+ scriptElement.addEventListener("load", () => document.body.focus());
+ document.head.appendChild(scriptElement);
+});
+</script>
+</head>
+</html>
Added: trunk/Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/Resources/DOMTestingUtil.js 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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
+ };
+})();
Added: trunk/Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/Resources/PlaybackHarness.html 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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=""
+<script src=""
+<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"><</button><button disabled id="nextButton">></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="" `;
+ 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>
Added: trunk/Tools/EditingHistory/EditingHistory/TestRunner.h (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/TestRunner.h (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/TestRunner.h 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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
Added: trunk/Tools/EditingHistory/EditingHistory/TestRunner.m (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/TestRunner.m (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/TestRunner.m 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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
Added: trunk/Tools/EditingHistory/EditingHistory/TestUtil.h (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/TestUtil.h (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/TestUtil.h 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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);
Added: trunk/Tools/EditingHistory/EditingHistory/TestUtil.m (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/TestUtil.m (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/TestUtil.m 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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);
+}
Added: trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.h 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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
Added: trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/WKWebViewAdditions.m 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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
Added: trunk/Tools/EditingHistory/EditingHistory/main.m (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory/main.m (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory/main.m 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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);
+}
Added: trunk/Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistory.xcodeproj/project.pbxproj 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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 */;
+}
Added: trunk/Tools/EditingHistory/EditingHistoryTests/Info.plist (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistoryTests/Info.plist (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistoryTests/Info.plist 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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>
Added: trunk/Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m (0 => 209470)
--- trunk/Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m (rev 0)
+++ trunk/Tools/EditingHistory/EditingHistoryTests/RewindAndPlaybackTests.m 2016-12-07 20:40:43 UTC (rev 209470)
@@ -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