1
0
mirror of https://github.com/adobe/brackets.git synced 2024-11-20 18:02:54 +01:00

Breaks HTMLInstrumentation into three files.

HTMLInstrumentation: handles the interface between editor/document
and live HTML.

HTMLSimpleDOM: Parses HTML into a simple DOM-like structure with
properties useful for HTMLInstrumentation.

HTMLDiff: Generates a list of edits to mutate a Simple DOMs into another.
This commit is contained in:
Kevin Dangoor 2013-09-03 11:27:18 -04:00
parent cc42e19659
commit f2aa82c858
6 changed files with 1172 additions and 1119 deletions

600
src/language/HTMLDiff.js Normal file
View File

@ -0,0 +1,600 @@
/*
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
/*global define, $, CodeMirror */
/*unittests: HTML Instrumentation*/
define(function (require, exports, module) {
"use strict";
function generateAttributeEdits(edits, oldNode, newNode) {
// shallow copy the old attributes object so that we can modify it
var oldAttributes = $.extend({}, oldNode.attributes),
newAttributes = newNode.attributes;
Object.keys(newAttributes).forEach(function (attributeName) {
if (oldAttributes[attributeName] !== newAttributes[attributeName]) {
var type = oldAttributes.hasOwnProperty(attributeName) ? "attrChange" : "attrAdd";
edits.push({
type: type,
tagID: oldNode.tagID,
attribute: attributeName,
value: newAttributes[attributeName]
});
}
delete oldAttributes[attributeName];
});
Object.keys(oldAttributes).forEach(function (attributeName) {
edits.push({
type: "attrDelete",
tagID: oldNode.tagID,
attribute: attributeName
});
});
}
/**
* Retrieve the parent tag ID of a SimpleDOM node.
*
* @param {Object} node SimpleDOM node for which to look up parent ID
* @return {int?} ID or null if there is no parent
*/
function getParentID(node) {
return node.parent && node.parent.tagID;
}
/**
* Generate a list of edits that will mutate oldNode to look like newNode.
* Currently, there are the following possible edit operations:
*
* * elementInsert
* * elementDelete
* * elementMove
* * textInsert
* * textDelete
* * textReplace
* * attrDelete
* * attrChange
* * attrAdd
* * rememberNodes (a special instruction that reflects the need to hang on to moved nodes)
*
* @param {Object} oldNode SimpleDOM node with the original content
* @param {Object} newNode SimpleDOM node with the new content
* @return {Array.{Object}} list of edit operations
*/
function domdiff(oldNode, newNode) {
var queue = [],
edits = [],
matches = {},
elementInserts = {},
textInserts = {},
textChanges = {},
elementsWithTextChanges = {},
currentElement,
oldElement,
moves = [],
elementDeletes = {},
oldNodeMap = oldNode ? oldNode.nodeMap : {};
/**
* When the main loop (see below) determines that something has changed with
* an element's immediate children, it calls this function to create edit
* operations for those changes.
*
* This adds to the edit list in place and does not return anything.
*
* @param {Object} currentParent SimpleDOM node for the current state of the element
* @param {?Object} oldParent SimpleDOM node for the previous state of this element, undefined if the element is new
*/
var generateChildEdits = function (currentParent, oldParent) {
/*jslint continue: true */
var currentIndex = 0,
oldIndex = 0,
currentChildren = currentParent.children,
oldChildren = oldParent ? oldParent.children : [],
currentChild,
oldChild,
newEdits = [],
newEdit,
textAfterID;
/**
* We initially put new edit objects into the `newEdits` array so that we
* can fix them up with proper positioning information. This function is
* responsible for doing that fixup.
*
* The `beforeID` that appears in many edits tells the browser to make the
* change before the element with the given ID. In other words, an
* elementInsert with a `beforeID` of 32 would result in something like
* `parentElement.insertBefore(newChildElement, _queryBracketsID(32))`
*
* Many new edits are captured in the `newEdits` array so that a suitable
* `beforeID` can be added to them before they are added to the main edits
* list. This function sets the `beforeID` on any pending edits and adds
* them to the main list.
*
* The beforeID set here will then be used as the `afterID` for text edits
* that follow.
*
* @param {int} beforeID ID to set on the pending edits
*/
var finalizeNewEdits = function (beforeID) {
newEdits.forEach(function (edit) {
// elementDeletes don't need any positioning information
if (edit.type !== "elementDelete") {
edit.beforeID = beforeID;
}
});
edits.push.apply(edits, newEdits);
newEdits = [];
textAfterID = beforeID;
};
/**
* If the current element was not in the old DOM, then we will create
* an elementInsert edit for it.
*
* If the element was in the old DOM, this will return false and the
* main loop will either spot this element later in the child list
* or the element has been moved.
*
* @return {boolean} true if an elementInsert was created
*/
var addElementInsert = function () {
if (!oldNodeMap[currentChild.tagID]) {
newEdit = {
type: "elementInsert",
tag: currentChild.tag,
tagID: currentChild.tagID,
parentID: currentChild.parent.tagID,
attributes: currentChild.attributes
};
newEdits.push(newEdit);
// This newly inserted node needs to have edits generated for its
// children, so we add it to the queue.
queue.push(currentChild);
// A textInsert edit that follows this elementInsert should use
// this element's ID.
textAfterID = currentChild.tagID;
// new element means we need to move on to compare the next
// of the current tree with the one from the old tree that we
// just compared
currentIndex++;
return true;
}
return false;
};
/**
* If the old element that we're looking at does not appear in the new
* DOM, that means it was deleted and we'll create an elementDelete edit.
*
* If the element is in the new DOM, then this will return false and
* the main loop with either spot this node later on or the element
* has been moved.
*
* @return {boolean} true if elementDelete was generated
*/
var addElementDelete = function () {
if (!newNode.nodeMap[oldChild.tagID]) {
newEdit = {
type: "elementDelete",
tagID: oldChild.tagID
};
newEdits.push(newEdit);
// deleted element means we need to move on to compare the next
// of the old tree with the one from the current tree that we
// just compared
oldIndex++;
return true;
}
return false;
};
/**
* Adds a textInsert edit for a newly created text node.
*/
var addTextInsert = function () {
newEdit = {
type: "textInsert",
content: currentChild.content,
parentID: currentChild.parent.tagID
};
// text changes will generally have afterID and beforeID, but we make
// special note if it's the first child.
if (textAfterID) {
newEdit.afterID = textAfterID;
} else {
newEdit.firstChild = true;
}
newEdits.push(newEdit);
// The text node is in the new tree, so we move to the next new tree item
currentIndex++;
};
/**
* Finds the previous child of the new tree.
*
* @return {?Object} previous child or null if there wasn't one
*/
var prevNode = function () {
if (currentIndex > 0) {
return currentParent.children[currentIndex - 1];
}
return null;
};
/**
* Adds a textDelete edit for text node that is not in the new tree.
* Note that we actually create a textReplace rather than a textDelete
* if the previous node in current tree was a text node. We do this because
* text nodes are not individually addressable and a delete event would
* end up clearing out both that previous text node that we want to keep
* and this text node that we want to eliminate. Instead, we just log
* a textReplace which will result in the deletion of this node and
* the maintaining of the old content.
*/
var addTextDelete = function () {
var prev = prevNode();
if (prev && !prev.children) {
newEdit = {
type: "textReplace",
content: prev.content
};
} else {
newEdit = {
type: "textDelete"
};
}
// When elements are deleted or moved from the old set of children, you
// can end up with multiple text nodes in a row. A single textReplace edit
// will take care of those (and will contain all of the right content since
// the text nodes between elements in the new DOM are merged together).
// The check below looks to see if we're already in the process of adding
// a textReplace edit following the same element.
var previousEdit = newEdits.length > 0 && newEdits[newEdits.length - 1];
if (previousEdit && previousEdit.type === "textReplace" &&
previousEdit.afterID === textAfterID) {
oldIndex++;
return;
}
newEdit.parentID = oldChild.parent.tagID;
// If there was only one child previously, we just pass along
// textDelete/textReplace with the parentID and the browser will
// clear all of the children
if (oldChild.parent.children.length === 1) {
newEdits.push(newEdit);
} else {
if (textAfterID) {
newEdit.afterID = textAfterID;
}
newEdits.push(newEdit);
}
// This text appeared in the old tree but not the new one, so we
// increment the old children counter.
oldIndex++;
};
/**
* Adds an elementMove edit if the parent has changed between the old and new trees.
* These are fairly infrequent and generally occur if you make a change across
* tag boundaries.
*
* @return {boolean} true if an elementMove was generated
*/
var addElementMove = function () {
// This check looks a little strange, but it suits what we're trying
// to do: as we're walking through the children, a child node that has moved
// from one parent to another will be found but would look like some kind
// of insert. The check that we're doing here is looking up the current
// child's ID in the *old* map and seeing if this child used to have a
// different parent.
var possiblyMovedElement = oldNodeMap[currentChild.tagID];
if (possiblyMovedElement &&
currentParent.tagID !== getParentID(possiblyMovedElement)) {
newEdit = {
type: "elementMove",
tagID: currentChild.tagID,
parentID: currentChild.parent.tagID
};
moves.push(newEdit.tagID);
newEdits.push(newEdit);
// this element in the new tree was a move to this spot, so we can move
// on to the next child in the new tree.
currentIndex++;
return true;
}
return false;
};
/**
* If there have been elementInserts before an unchanged text, we need to
* let the browser side code know that these inserts should happen *before*
* that unchanged text.
*/
var fixupElementInsert = function () {
newEdits.forEach(function (edit) {
if (edit.type === "elementInsert") {
edit.beforeText = true;
}
});
};
/**
* Looks to see if the element in the old tree has moved by checking its
* current and former parents.
*
* @return {boolean} true if the element has moved
*/
var hasMoved = function (oldChild) {
var oldChildInNewTree = newNode.nodeMap[oldChild.tagID];
return oldChild.children && oldChildInNewTree && getParentID(oldChild) !== getParentID(oldChildInNewTree);
};
// Loop through the current and old children, comparing them one by one.
while (currentIndex < currentChildren.length && oldIndex < oldChildren.length) {
currentChild = currentChildren[currentIndex];
// Check to see if the currentChild has been reparented from somewhere
// else in the old tree
if (currentChild.children && addElementMove()) {
continue;
}
oldChild = oldChildren[oldIndex];
// Check to see if the oldChild has been moved to another parent.
// If it has, we deal with it on the other side (see above)
if (hasMoved(oldChild)) {
oldIndex++;
continue;
}
// First check: is one an element?
if (currentChild.children || oldChild.children) {
// Current child is an element, old child is a text node
if (currentChild.children && !oldChild.children) {
addTextDelete();
// If this element is new, add it and move to the next child
// in the current tree. Otherwise, we'll compare this same
// current element with the next old element on the next pass
// through the loop.
addElementInsert();
// Current child is a text node, old child is an element
} else if (oldChild.children && !currentChild.children) {
// If the old child has *not* been deleted, we assume that we've
// inserted some text and will still encounter the old node
if (!addElementDelete()) {
addTextInsert();
}
// both children are elements
} else {
if (currentChild.tagID !== oldChild.tagID) {
// These are different elements, so we will add an insert and/or delete
// as appropriate
if (!addElementInsert() && !addElementDelete()) {
console.error("HTML Instrumentation: This should not happen. Two elements have different tag IDs and there was no insert/delete. This generally means there was a reordering of elements.");
currentIndex++;
oldIndex++;
}
// There has been no change in the tag we're looking at.
} else {
// Since this element hasn't moved, it is a suitable "beforeID"
// for the edits we've logged.
finalizeNewEdits(oldChild.tagID);
currentIndex++;
oldIndex++;
}
}
// We know we're comparing two texts. Just match up their signatures.
} else {
if (currentChild.textSignature !== oldChild.textSignature) {
newEdit = {
type: "textReplace",
content: currentChild.content,
parentID: currentChild.parent.tagID
};
if (textAfterID) {
newEdit.afterID = textAfterID;
}
newEdits.push(newEdit);
} else {
// This is a special case: if an element is being inserted but
// there is an unchanged text that follows it, the element being
// inserted may end up in the wrong place because it will get a
// beforeID of the next element when it really needs to come
// before this unchanged text.
fixupElementInsert();
}
// Either we've done a text replace or both sides matched. In either
// case we're ready to move forward among both the old and new children.
currentIndex++;
oldIndex++;
}
}
// At this point, we've used up all of the children in at least one of the
// two sets of children.
/**
* Take care of any remaining children in the old tree.
*/
while (oldIndex < oldChildren.length) {
oldChild = oldChildren[oldIndex];
// Check for an element that has moved
if (hasMoved(oldChild)) {
// This element has moved, so we skip it on this side (the move
// is handled on the new tree side).
oldIndex++;
// is this an element? if so, delete it
} else if (oldChild.children) {
if (!addElementDelete()) {
console.error("HTML Instrumentation: failed to add elementDelete for remaining element in the original DOM. This should not happen.", oldChild);
oldIndex++;
}
// must be text. delete that.
} else {
addTextDelete();
}
}
/**
* Take care of the remaining children in the new tree.
*/
while (currentIndex < currentChildren.length) {
currentChild = currentChildren[currentIndex];
// Is this an element?
if (currentChild.children) {
// Look to see if the element has moved here.
if (!addElementMove()) {
// Not a move, so we insert this element.
if (!addElementInsert()) {
console.error("HTML Instrumentation: failed to add elementInsert for remaining element in the updated DOM. This should not happen.");
currentIndex++;
}
}
// not a new element, so it must be new text.
} else {
addTextInsert();
}
}
/**
* Finalize remaining edits. For inserts and moves, we can set the `lastChild`
* flag and the browser can simply use `appendChild` to add these items.
*/
newEdits.forEach(function (edit) {
if (edit.type === "textInsert" || edit.type === "elementInsert" || edit.type === "elementMove") {
edit.lastChild = true;
delete edit.firstChild;
delete edit.afterID;
}
});
edits.push.apply(edits, newEdits);
};
/**
* Adds elements to the queue for generateChildEdits.
* Only elements (and not text nodes) are added. New nodes (ones that aren't in the
* old nodeMap), are not added here because they will be added when generateChildEdits
* creates the elementInsert edit.
*/
var queuePush = function (node) {
if (node.children && oldNodeMap[node.tagID]) {
queue.push(node);
}
};
// Start at the root of the current tree.
queue.push(newNode);
do {
currentElement = queue.pop();
oldElement = oldNodeMap[currentElement.tagID];
// Do we need to compare elements?
if (oldElement) {
// Are attributes different?
if (currentElement.attributeSignature !== oldElement.attributeSignature) {
// generate attribute edits
generateAttributeEdits(edits, oldElement, currentElement);
}
// Has there been a change to this node's immediate children?
if (currentElement.childSignature !== oldElement.childSignature) {
generateChildEdits(currentElement, oldElement);
}
// If there's a change farther down in the tree, add the children to the queue.
// If not, we can skip that whole subtree.
if (currentElement.subtreeSignature !== oldElement.subtreeSignature) {
currentElement.children.forEach(queuePush);
}
// This is a new element, so go straight to generating child edits (which will
// create the appropriate Insert edits).
} else {
// If this is the root (html) tag, we need to manufacture an insert for it here,
// because it isn't the child of any other node. The browser-side code doesn't
// care about parentage/positioning in this case, and will handle just setting the
// ID on the existing implied HTML tag in the browser without actually creating it.
if (!currentElement.parent) {
edits.push({
type: "elementInsert",
tag: currentElement.tag,
tagID: currentElement.tagID,
parentID: null,
attributes: currentElement.attributes
});
}
generateChildEdits(currentElement, null);
}
} while (queue.length);
// Special handling for moves: add edits to the beginning of the list so that
// moved nodes are set aside to ensure that they remain available at the time of their
// move.
if (moves.length > 0) {
edits.unshift({
type: "rememberNodes",
tagIDs: moves
});
}
return edits;
}
exports.domdiff = domdiff;
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,441 @@
/*
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
/*global define, $, CodeMirror */
/*unittests: HTML Instrumentation*/
define(function (require, exports, module) {
"use strict";
var DocumentManager = require("document/DocumentManager"),
Tokenizer = require("language/HTMLTokenizer").Tokenizer,
MurmurHash3 = require("thirdparty/murmurhash3_gc"),
PerfUtils = require("utils/PerfUtils");
var seed = Math.floor(Math.random() * 65535);
var tagID = 1;
/**
* A list of tags whose start causes any of a given set of immediate parent
* tags to close. This mostly comes from the HTML5 spec section on omitted close tags:
* http://www.w3.org/html/wg/drafts/html/master/syntax.html#optional-tags
* This doesn't handle general content model violations.
*/
var openImpliesClose = {
li : { li: true },
dt : { dd: true, dt: true },
dd : { dd: true, dt: true },
address : { p: true },
article : { p: true },
aside : { p: true },
blockquote : { p: true },
dir : { p: true },
div : { p: true },
dl : { p: true },
fieldset: { p: true },
footer : { p: true },
form : { p: true },
h1 : { p: true },
h2 : { p: true },
h3 : { p: true },
h4 : { p: true },
h5 : { p: true },
h6 : { p: true },
header : { p: true },
hgroup : { p: true },
hr : { p: true },
main : { p: true },
menu : { p: true },
nav : { p: true },
ol : { p: true },
p : { p: true },
pre : { p: true },
section : { p: true },
table : { p: true },
ul : { p: true },
rt : { rp: true, rt: true },
rp : { rp: true, rt: true },
optgroup: { optgroup: true, option: true },
option : { option: true },
tbody : { thead: true, tbody: true, tfoot: true },
tfoot : { tbody: true },
tr : { tr: true, th: true, td: true },
th : { th: true, td: true },
td : { thead: true, th: true, td: true },
body : { head: true, link: true, script: true }
};
/**
* A list of tags that are self-closing (do not contain other elements).
* Mostly taken from http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements
*/
var voidElements = {
area: true,
base: true,
basefont: true,
br: true,
col: true,
command: true,
embed: true,
frame: true,
hr: true,
img: true,
input: true,
isindex: true,
keygen: true,
link: true,
menuitem: true,
meta: true,
param: true,
source: true,
track: true,
wbr: true
};
function SimpleDOMBuilder(text, startOffset, startOffsetPos) {
this.stack = [];
this.text = text;
this.t = new Tokenizer(text);
this.currentTag = null;
this.startOffset = startOffset || 0;
this.startOffsetPos = startOffsetPos || {line: 0, ch: 0};
}
function _updateHash(node) {
if (node.children) {
var i,
subtreeHashes = "",
childHashes = "",
child;
for (i = 0; i < node.children.length; i++) {
child = node.children[i];
if (child.children) {
childHashes += String(child.tagID);
subtreeHashes += String(child.tagID) + child.attributeSignature + child.subtreeSignature;
} else {
childHashes += child.textSignature;
subtreeHashes += child.textSignature;
}
}
node.childSignature = MurmurHash3.hashString(childHashes, childHashes.length, seed);
node.subtreeSignature = MurmurHash3.hashString(subtreeHashes, subtreeHashes.length, seed);
} else {
node.textSignature = MurmurHash3.hashString(node.content, node.content.length, seed);
}
}
function _updateAttributeHash(node) {
var attributeString = JSON.stringify(node.attributes);
node.attributeSignature = MurmurHash3.hashString(attributeString, attributeString.length, seed);
}
/**
* Generates a synthetic ID for text nodes. These IDs are only used
* for comparison purposes in the SimpleDOM structure, since we can't
* apply IDs to text nodes in the browser.
*
* TODO/Note: When generating a diff, the decision to do a textReplace
* edit rather than a textDelete/textInsert hinges on how this ID
* is treated (because it is currently based on the previous or
* parent node).
*
* @param {Object} textNode new node for which we are generating an ID
* @return {string} ID for the node
*/
function getTextNodeID(textNode) {
var childIndex = textNode.parent.children.indexOf(textNode);
if (childIndex === 0) {
return textNode.parent.tagID + ".0";
}
return textNode.parent.children[childIndex - 1].tagID + "t";
}
function addPos(pos1, pos2) {
return {line: pos1.line + pos2.line, ch: (pos2.line === 0 ? pos1.ch + pos2.ch : pos2.ch)};
}
function offsetPos(pos, offset) {
// Simple character offset. Only safe if the offset doesn't cross a line boundary.
return {line: pos.line, ch: pos.ch + offset};
}
SimpleDOMBuilder.prototype.build = function (strict) {
var self = this;
var token, lastClosedTag, lastTextNode, lastIndex = 0;
var stack = this.stack;
var attributeName = null;
var nodeMap = {};
var markCache = {};
// Start timers for building full and partial DOMs.
// Appropriate timer is used, and the other is discarded.
var timerBuildFull = "HTMLInstr. Build DOM Full";
var timerBuildPart = "HTMLInstr. Build DOM Partial";
PerfUtils.markStart([timerBuildFull, timerBuildPart]);
function closeTag(endIndex, endPos) {
lastClosedTag = stack[stack.length - 1];
stack.pop();
_updateHash(lastClosedTag);
lastClosedTag.end = self.startOffset + endIndex;
lastClosedTag.endPos = addPos(self.startOffsetPos, endPos);
}
while ((token = this.t.nextToken()) !== null) {
// lastTextNode is used to glue text nodes together
// If the last node we saw was text but this one is not, then we're done gluing.
// If this node is a comment, we might still encounter more text.
if (token.type !== "text" && token.type !== "comment" && lastTextNode) {
lastTextNode = null;
}
if (token.type === "error") {
PerfUtils.finalizeMeasurement(timerBuildFull); // discard
PerfUtils.addMeasurement(timerBuildPart); // use
return null;
} else if (token.type === "opentagname") {
var newTagName = token.contents.toLowerCase(),
newTag;
if (openImpliesClose.hasOwnProperty(newTagName)) {
var closable = openImpliesClose[newTagName];
while (stack.length > 0 && closable.hasOwnProperty(stack[stack.length - 1].tag)) {
// Close the previous tag at the start of this tag.
// Adjust backwards for the < before the tag name.
closeTag(token.start - 1, offsetPos(token.startPos, -1));
}
}
newTag = {
tag: token.contents.toLowerCase(),
children: [],
attributes: {},
parent: (stack.length ? stack[stack.length - 1] : null),
start: this.startOffset + token.start - 1,
startPos: addPos(this.startOffsetPos, offsetPos(token.startPos, -1)) // ok because we know the previous char was a "<"
};
newTag.tagID = this.getID(newTag, markCache);
// During undo in particular, it's possible that tag IDs may be reused and
// the marks in the document may be misleading. If a tag ID has been reused,
// we apply a new tag ID to ensure that our edits come out correctly.
if (nodeMap[newTag.tagID]) {
newTag.tagID = this.getNewID();
}
nodeMap[newTag.tagID] = newTag;
if (newTag.parent) {
newTag.parent.children.push(newTag);
}
this.currentTag = newTag;
if (voidElements.hasOwnProperty(newTag.tag)) {
// This is a self-closing element.
_updateHash(newTag);
} else {
stack.push(newTag);
}
} else if (token.type === "opentagend" || token.type === "selfclosingtag") {
// TODO: disallow <p/>?
if (this.currentTag) {
// We're closing an open tag. Record the end of the open tag as the end of the
// range. (If we later find a close tag for this tag, the end will get overwritten
// with the end of the close tag. In the case of a self-closing tag, we should never
// encounter that.)
// Note that we don't need to update the signature here because the signature only
// relies on the tag name and ID, and isn't affected by the tag's attributes, so
// the signature we calculated when creating the tag is still the same. If we later
// find a close tag for this tag, we'll update the signature to account for its
// children at that point (in the next "else" case).
this.currentTag.end = this.startOffset + token.end;
this.currentTag.endPos = addPos(this.startOffsetPos, token.endPos);
lastClosedTag = this.currentTag;
_updateAttributeHash(this.currentTag);
this.currentTag = null;
}
} else if (token.type === "closetag") {
// If this is a self-closing element, ignore the close tag.
var closeTagName = token.contents.toLowerCase();
if (!voidElements.hasOwnProperty(closeTagName)) {
// Find the topmost item on the stack that matches. If we can't find one, assume
// this is just a dangling closing tag and ignore it.
var i;
for (i = stack.length - 1; i >= 0; i--) {
if (stack[i].tag === closeTagName) {
break;
}
}
if (strict && i !== stack.length - 1) {
// If we're in strict mode, treat unbalanced tags as invalid.
PerfUtils.finalizeMeasurement(timerBuildFull);
PerfUtils.addMeasurement(timerBuildPart);
return null;
}
if (i >= 0) {
do {
// For all tags we're implicitly closing (before we hit the matching tag), we want the
// implied end to be the beginning of the close tag (which is two characters, "</", before
// the start of the tagname). For the actual tag we're explicitly closing, we want the
// implied end to be the end of the close tag (which is one character, ">", after the end of
// the tagname).
if (stack.length === i + 1) {
closeTag(token.end + 1, offsetPos(token.endPos, 1));
} else {
closeTag(token.start - 2, offsetPos(token.startPos, -2));
}
} while (stack.length > i);
} else {
// If we're in strict mode, treat unmatched close tags as invalid. Otherwise
// we just silently ignore them.
if (strict) {
PerfUtils.finalizeMeasurement(timerBuildFull);
PerfUtils.addMeasurement(timerBuildPart);
return null;
}
}
}
} else if (token.type === "attribname") {
attributeName = token.contents.toLowerCase();
// Set the value to the empty string in case this is an empty attribute. If it's not,
// it will get overwritten by the attribvalue later.
this.currentTag.attributes[attributeName] = "";
} else if (token.type === "attribvalue" && attributeName !== null) {
this.currentTag.attributes[attributeName] = token.contents;
attributeName = null;
} else if (token.type === "text") {
if (stack.length) {
var parent = stack[stack.length - 1];
var newNode;
// Check to see if we're continuing a previous text.
if (lastTextNode) {
newNode = lastTextNode;
newNode.content += token.contents;
} else {
newNode = {
parent: stack[stack.length - 1],
content: token.contents
};
parent.children.push(newNode);
newNode.tagID = getTextNodeID(newNode);
nodeMap[newNode.tagID] = newNode;
lastTextNode = newNode;
}
_updateHash(newNode);
}
}
lastIndex = token.end;
}
// If we have any tags hanging open (e.g. html or body), fail the parse if we're in strict mode,
// otherwise close them at the end of the document.
if (stack.length) {
if (strict) {
PerfUtils.finalizeMeasurement(timerBuildFull);
PerfUtils.addMeasurement(timerBuildPart);
return null;
} else {
// Manually compute the position of the end of the text (we can't rely on the
// tokenizer for this since it may not get to the very end)
// TODO: should probably make the tokenizer get to the end...
var lines = this.text.split("\n"),
lastPos = {line: lines.length - 1, ch: lines[lines.length - 1].length};
while (stack.length) {
closeTag(this.text.length, lastPos);
}
}
}
var dom = lastClosedTag;
if (!dom) {
// This can happen if the document has no nontrivial content, or if the user tries to
// have something at the root other than the HTML tag. In all such cases, we treat the
// document as invalid.
return null;
}
dom.nodeMap = nodeMap;
PerfUtils.addMeasurement(timerBuildFull); // use
PerfUtils.finalizeMeasurement(timerBuildPart); // discard
return dom;
};
/**
* Returns a new tag ID.
*
* @return {int} unique tag ID
*/
SimpleDOMBuilder.prototype.getNewID = function () {
return tagID++;
};
/**
* Returns the best tag ID for the new tag object given.
* The default implementation just calls `getNewID`
* and returns a unique ID.
*
* @param {Object} newTag tag object to potentially inspect to choose an ID
* @return {int} unique tag ID
*/
SimpleDOMBuilder.prototype.getID = SimpleDOMBuilder.prototype.getNewID;
function buildSimpleDOM(text, strict) {
var builder = new SimpleDOMBuilder(text);
return builder.build(strict);
}
function _dumpDOM(root) {
var result = "",
indent = "";
function walk(node) {
if (node.tag) {
result += indent + "TAG " + node.tagID + " " + node.tag + " " + JSON.stringify(node.attributes) + "\n";
} else {
result += indent + "TEXT " + node.tagID + " " + node.content + "\n";
}
if (node.children) {
indent += " ";
node.children.forEach(walk);
indent = indent.slice(2);
}
}
walk(root);
return result;
}
exports._dumpDOM = _dumpDOM;
exports.buildSimpleDOM = buildSimpleDOM;
exports._offsetPos = offsetPos;
exports._updateHash = _updateHash;
exports._getTextNodeID = getTextNodeID;
exports._seed = seed;
exports.SimpleDOMBuilder = SimpleDOMBuilder;
});

View File

@ -46,6 +46,7 @@ define(function (require, exports, module) {
require("spec/FileUtils-test");
require("spec/FindReplace-test");
require("spec/HTMLInstrumentation-test");
require("spec/HTMLSimpleDOM-test");
require("spec/HTMLTokenizer-test");
require("spec/InlineEditorProviders-test");
require("spec/InstallExtensionDialog-test");

View File

@ -33,9 +33,9 @@ define(function (require, exports, module) {
var NativeFileSystem = require("file/NativeFileSystem").NativeFileSystem,
FileUtils = require("file/FileUtils"),
HTMLInstrumentation = require("language/HTMLInstrumentation"),
HTMLSimpleDOM = require("language/HTMLSimpleDOM"),
RemoteFunctions = require("text!LiveDevelopment/Agents/RemoteFunctions.js"),
SpecRunnerUtils = require("spec/SpecRunnerUtils"),
MurmurHash3 = require("thirdparty/murmurhash3_gc"),
WellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/wellformed.html"),
NotWellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/omitEndTags.html"),
InvalidHTMLDoc = require("text!spec/HTMLInstrumentation-test-files/invalidHTML.html"),
@ -53,7 +53,7 @@ define(function (require, exports, module) {
// starting from an empty document (which, in the browser, includes implied html/head/body
// tags). We have to also strip the tagIDs from this DOM since they won't appear in the
// browser in this case.
var dom = HTMLInstrumentation._buildSimpleDOM("<html><head></head><body></body></html>", true);
var dom = HTMLSimpleDOM.buildSimpleDOM("<html><head></head><body></body></html>", true);
Object.keys(dom.nodeMap).forEach(function (key) {
var node = dom.nodeMap[key];
delete node.tagID;
@ -931,44 +931,6 @@ define(function (require, exports, module) {
});
});
describe("Strict HTML parsing", function () {
it("should parse a document with balanced, void and self-closing tags", function () {
expect(HTMLInstrumentation._buildSimpleDOM("<p><b>some</b>awesome text</p><p>and <img> another <br/> para</p>", true)).not.toBeNull();
});
it("should parse a document with an implied-close tag followed by a tag that forces it to close", function () {
var result = HTMLInstrumentation._buildSimpleDOM("<div><p>unclosed para<h1>heading that closes para</h1></div>", true);
expect(result).not.toBeNull();
expect(result.tag).toBe("div");
expect(result.children[0].tag).toBe("p");
expect(result.children[1].tag).toBe("h1");
});
it("should return null for an unclosed non-void/non-implied-close tag", function () {
expect(HTMLInstrumentation._buildSimpleDOM("<p>this has an <b>unclosed bold tag</p>", true)).toBeNull();
});
it("should return null for an extra close tag", function () {
expect(HTMLInstrumentation._buildSimpleDOM("<p>this has an unopened bold</b> tag</p>", true)).toBeNull();
});
it("should return null if there are unclosed tags at the end of the document", function () {
expect(HTMLInstrumentation._buildSimpleDOM("<div>this has <b>multiple unclosed tags", true)).toBeNull();
});
it("should return null if there is a tokenization failure", function () {
expect(HTMLInstrumentation._buildSimpleDOM("<div<badtag></div>", true)).toBeNull();
});
it("should handle empty attributes", function () {
var dom = HTMLInstrumentation._buildSimpleDOM("<input disabled>", true);
expect(dom.attributes.disabled).toEqual("");
});
it("should merge text nodes around a comment", function () {
var dom = HTMLInstrumentation._buildSimpleDOM("<div>Text <!-- comment --> Text2</div>", true);
expect(dom.children.length).toBe(1);
var textNode = dom.children[0];
expect(textNode.content).toBe("Text Text2");
expect(textNode.textSignature).toBeDefined();
});
});
describe("HTML Instrumentation in dirty files", function () {
var changeList, offsets;
@ -1009,7 +971,7 @@ define(function (require, exports, module) {
editor.document.refreshText(origText);
}
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
result;
var clonedDOM = cloneDOM(previousDOM);
@ -1121,33 +1083,10 @@ define(function (require, exports, module) {
});
});
it("should build simple DOM", function () {
setupEditor(WellFormedDoc);
runs(function () {
var dom = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
expect(dom.tagID).toEqual(jasmine.any(Number));
expect(dom.tag).toEqual("html");
expect(dom.start).toEqual(16);
expect(dom.end).toEqual(1269);
expect(dom.subtreeSignature).toEqual(jasmine.any(Number));
expect(dom.childSignature).toEqual(jasmine.any(Number));
expect(dom.children.length).toEqual(5);
var meta = dom.children[1].children[1];
expect(Object.keys(meta.attributes).length).toEqual(1);
expect(meta.attributes.charset).toEqual("utf-8");
var titleContents = dom.children[1].children[5].children[0];
expect(titleContents.content).toEqual("GETTING STARTED WITH BRACKETS");
expect(titleContents.textSignature).toEqual(MurmurHash3.hashString(titleContents.content, titleContents.content.length, HTMLInstrumentation._seed));
expect(dom.children[1].parent).toEqual(dom);
expect(dom.nodeMap[meta.tagID]).toBe(meta);
expect(meta.childSignature).toEqual(jasmine.any(Number));
});
});
it("should mark editor text based on the simple DOM", function () {
setupEditor(WellFormedDoc);
runs(function () {
var dom = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
var dom = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText());
HTMLInstrumentation._markTextFromDOM(editor, dom);
expect(editor._codeMirror.getAllMarks().length).toEqual(15);
});
@ -1156,7 +1095,7 @@ define(function (require, exports, module) {
it("should handle no diff", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText());
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
var result = HTMLInstrumentation._updateDOM(previousDOM, editor);
expect(result.edits).toEqual([]);
@ -1301,7 +1240,7 @@ define(function (require, exports, module) {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID = previousDOM.children[3].children[1].tagID,
result,
origParent = previousDOM.children[3];
@ -1349,7 +1288,7 @@ define(function (require, exports, module) {
it("should avoid updating while typing an incomplete tag, then update when it's done", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
@ -1402,7 +1341,7 @@ define(function (require, exports, module) {
it("should handle typing of a <p> without a </p> and then adding it later", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
@ -1485,7 +1424,7 @@ define(function (require, exports, module) {
it("should handle deleting of an empty tag character-by-character", function () {
setupEditor("<p><img>{{0}}</p>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
imgTagID = previousDOM.children[0].tagID,
result;
@ -1506,7 +1445,7 @@ define(function (require, exports, module) {
it("should handle deleting of a non-empty tag character-by-character", function () {
setupEditor("<div><p>deleteme</p>{{0}}</div>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
pTagID = previousDOM.children[0].tagID,
result;
@ -1527,7 +1466,7 @@ define(function (require, exports, module) {
it("should handle typing of a new attribute character-by-character", function () {
setupEditor("<p{{0}}>some text</p>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID = previousDOM.tagID,
result;
@ -1579,7 +1518,7 @@ define(function (require, exports, module) {
it("should handle deleting of an attribute character-by-character", function () {
setupEditor("<p class='myclass'{{0}}>some text</p>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID = previousDOM.tagID,
result;
@ -1627,7 +1566,7 @@ define(function (require, exports, module) {
it("should handle wrapping a tag around some text character by character", function () {
setupEditor("<p>{{0}}some text{{1}}</p>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID = previousDOM.tagID,
result;
@ -1674,7 +1613,7 @@ define(function (require, exports, module) {
it("should handle adding an <html> tag into an empty document", function () {
setupEditor("");
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID,
result;
@ -1712,7 +1651,7 @@ define(function (require, exports, module) {
it("should handle adding a <head> tag into a document", function () {
setupEditor("<html>{{0}}</html>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID,
result;
@ -1745,7 +1684,7 @@ define(function (require, exports, module) {
it("should handle adding a <body> tag into a document", function () {
setupEditor("<html><head></head>{{0}}</html>", true);
runs(function () {
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
tagID,
result;
@ -2639,7 +2578,7 @@ define(function (require, exports, module) {
}
benchmarker.start("Initial DOM build");
var previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText),
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText),
changeList,
result;
benchmarker.end("Initial DOM build");
@ -2662,7 +2601,7 @@ define(function (require, exports, module) {
for (i = 0; i < runs; i++) {
editor.document.setText(previousText);
previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText);
previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText);
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
$(editor).on("change.perftest", changeFunction);
@ -2678,7 +2617,7 @@ define(function (require, exports, module) {
for (i = 0; i < runs; i++) {
editor.document.setText(previousText);
previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText);
previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText);
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
$(editor).on("change.perftest", fullChangeFunction);
@ -2816,22 +2755,5 @@ define(function (require, exports, module) {
});
});
});
xdescribe("DOMNavigator", function () {
it("implements easy depth-first traversal", function () {
var dom = HTMLInstrumentation._buildSimpleDOM("<html><body><div>Here is <strong>my text</strong></div></body></html>");
var nav = new HTMLInstrumentation._DOMNavigator(dom);
expect(nav.next().tag).toEqual("body");
expect(nav.next().tag).toEqual("div");
expect(nav.next().content).toEqual("Here is ");
expect(nav.getPosition()).toEqual({
tagID: dom.children[0].children[0].tagID,
child: 0
});
expect(nav.next().tag).toEqual("strong");
expect(nav.next().content).toEqual("my text");
expect(nav.next()).toBeNull();
});
});
});
});

View File

@ -0,0 +1,101 @@
/*
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, regexp: true, indent: 4, maxerr: 50, evil: true */
/*global define, $, describe, beforeEach, afterEach, it, runs, waitsFor, expect, spyOn, xit, xdescribe, jasmine, Node */
/*unittests: HTML SimpleDOM*/
define(function (require, exports, module) {
"use strict";
var HTMLSimpleDOM = require("language/HTMLSimpleDOM"),
WellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/wellformed.html"),
MurmurHash3 = require("thirdparty/murmurhash3_gc");
describe("HTML SimpleDOM", function () {
describe("Strict HTML parsing", function () {
it("should parse a document with balanced, void and self-closing tags", function () {
expect(HTMLSimpleDOM.buildSimpleDOM("<p><b>some</b>awesome text</p><p>and <img> another <br/> para</p>", true)).not.toBeNull();
});
it("should parse a document with an implied-close tag followed by a tag that forces it to close", function () {
var result = HTMLSimpleDOM.buildSimpleDOM("<div><p>unclosed para<h1>heading that closes para</h1></div>", true);
expect(result).not.toBeNull();
expect(result.tag).toBe("div");
expect(result.children[0].tag).toBe("p");
expect(result.children[1].tag).toBe("h1");
});
it("should return null for an unclosed non-void/non-implied-close tag", function () {
expect(HTMLSimpleDOM.buildSimpleDOM("<p>this has an <b>unclosed bold tag</p>", true)).toBeNull();
});
it("should return null for an extra close tag", function () {
expect(HTMLSimpleDOM.buildSimpleDOM("<p>this has an unopened bold</b> tag</p>", true)).toBeNull();
});
it("should return null if there are unclosed tags at the end of the document", function () {
expect(HTMLSimpleDOM.buildSimpleDOM("<div>this has <b>multiple unclosed tags", true)).toBeNull();
});
it("should return null if there is a tokenization failure", function () {
expect(HTMLSimpleDOM.buildSimpleDOM("<div<badtag></div>", true)).toBeNull();
});
it("should handle empty attributes", function () {
var dom = HTMLSimpleDOM.buildSimpleDOM("<input disabled>", true);
expect(dom.attributes.disabled).toEqual("");
});
it("should merge text nodes around a comment", function () {
var dom = HTMLSimpleDOM.buildSimpleDOM("<div>Text <!-- comment --> Text2</div>", true);
expect(dom.children.length).toBe(1);
var textNode = dom.children[0];
expect(textNode.content).toBe("Text Text2");
expect(textNode.textSignature).toBeDefined();
});
it("should build simple DOM", function () {
var dom = HTMLSimpleDOM.buildSimpleDOM(WellFormedDoc);
expect(dom.tagID).toEqual(jasmine.any(Number));
expect(dom.tag).toEqual("html");
expect(dom.start).toEqual(16);
expect(dom.end).toEqual(1269);
expect(dom.subtreeSignature).toEqual(jasmine.any(Number));
expect(dom.childSignature).toEqual(jasmine.any(Number));
expect(dom.children.length).toEqual(5);
var meta = dom.children[1].children[1];
expect(Object.keys(meta.attributes).length).toEqual(1);
expect(meta.attributes.charset).toEqual("utf-8");
var titleContents = dom.children[1].children[5].children[0];
expect(titleContents.content).toEqual("GETTING STARTED WITH BRACKETS");
expect(titleContents.textSignature).toEqual(MurmurHash3.hashString(titleContents.content, titleContents.content.length, HTMLSimpleDOM._seed));
expect(dom.children[1].parent).toEqual(dom);
expect(dom.nodeMap[meta.tagID]).toBe(meta);
expect(meta.childSignature).toEqual(jasmine.any(Number));
});
});
});
});