1
0
mirror of https://github.com/adobe/brackets.git synced 2024-11-20 18:02:54 +01:00
brackets/test/spec/HTMLInstrumentation-test.js
Jan Schär 1cfb24fd93 Allow omitted optional close tags in HTMLSimpleDOM (#12057)
* Allow omitted optional close tags in HTMLSimpleDOM

fixes #7257
This commit also updates the openImpliesClose list.

* Fix TypeError after typing at end of html document

This fixes `TypeError: Cannot read property 'mark' of null`

* Make shure Tokenizer gets to the end

and does not skip text nodes at the end.

* Fix preferParent in HTMLInstrumentation

* Allow dt at end of dl

...even though it's not actually valid. Change requested by @petetnt
2016-10-25 23:08:22 +03:00

2706 lines
128 KiB
JavaScript

/*
* Copyright (c) 2013 - present 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 evil: true, regexp: true */
/*global describe, beforeEach, afterEach, it, runs, expect, spyOn, jasmine, Node */
/*unittests: HTML Instrumentation*/
define(function (require, exports, module) {
"use strict";
// Load dependent modules
var HTMLInstrumentation = require("language/HTMLInstrumentation"),
HTMLSimpleDOM = require("language/HTMLSimpleDOM"),
RemoteFunctions = require("text!LiveDevelopment/Agents/RemoteFunctions.js"),
SpecRunnerUtils = require("spec/SpecRunnerUtils"),
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");
RemoteFunctions = eval("(" + RemoteFunctions.trim() + ")()");
var editor,
instrumentedHTML,
elementCount,
elementIds = {};
function createBlankDOM() {
// This creates a DOM for a blank document that we can clone when we want to simulate
// 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 = HTMLSimpleDOM.build("<html><head></head><body></body></html>", true);
Object.keys(dom.nodeMap).forEach(function (key) {
var node = dom.nodeMap[key];
delete node.tagID;
});
dom.nodeMap = {};
return dom;
}
function removeDescendentsFromNodeMap(nodeMap, node) {
delete nodeMap[node.tagID];
if (node.children) {
node.children.forEach(function (child) {
removeDescendentsFromNodeMap(nodeMap, child);
});
}
}
var entityParsingNode = window.document.createElement("div");
/**
* domFeatures is a prototype object that augments a SimpleDOM object to have more of the
* features of a real DOM object. It specifically adds the features required for
* the RemoteFunctions code that applies patches and is not a general DOM implementation.
*
* Standard DOM methods below are not documented, but the ones unique to this test harness
* are.
*/
var domFeatures = Object.create(new HTMLSimpleDOM.SimpleNode(), {
firstChild: {
get: function () {
return this.children[0];
}
},
lastChild: {
get: function () {
return this.children[this.children.length - 1];
}
},
siblings: {
get: function () {
return this.parent.children;
}
},
nextSibling: {
get: function () {
var siblings = this.siblings;
var index = siblings.indexOf(this);
return siblings[index + 1];
}
},
previousSibling: {
get: function () {
var siblings = this.siblings;
var index = siblings.indexOf(this);
return siblings[index - 1];
}
},
nodeType: {
get: function () {
if (this.children) {
return Node.ELEMENT_NODE;
} else if (this.content) {
return Node.TEXT_NODE;
}
}
},
childNodes: {
get: function () {
var children = this.children;
if (!children.item) {
children.item = function (index) {
return children[index];
};
}
return children;
}
},
// At this time, innerHTML and textContent are used for entity parsing
// only. If that changes, we'll have bigger issues to deal with.
innerHTML: {
set: function (text) {
entityParsingNode.innerHTML = text;
},
get: function () {
return entityParsingNode.innerHTML;
}
},
textContent: {
set: function (text) {
entityParsingNode.textContent = text;
},
get: function () {
return entityParsingNode.textContent;
}
}
});
$.extend(domFeatures, {
insertBefore: function (newElement, referenceElement) {
if (newElement.parent && newElement.parent !== this) {
newElement.remove();
}
var index = this.children.indexOf(referenceElement);
if (index === -1) {
console.error("Unexpected attempt to reference a non-existent element:", referenceElement);
console.log(this.children);
}
this.children.splice(index, 0, newElement);
newElement.parent = this;
newElement.addToNodeMap();
},
appendChild: function (newElement) {
if (newElement.parent && newElement.parent !== this) {
newElement.remove();
}
this.children.push(newElement);
newElement.parent = this;
newElement.addToNodeMap();
},
/**
* The nodeMap keeps track of the Brackets-assigned tag ID to node object mapping.
* This method adds this element to the nodeMap if it has a data-brackets-id
* attribute set (something that the client-side applyEdits code will do).
*/
addToNodeMap: function () {
if (this.attributes && this.attributes["data-brackets-id"]) {
var nodeMap = this.getNodeMap();
if (nodeMap) {
nodeMap[this.attributes["data-brackets-id"]] = this;
} else {
console.error("Unable to get nodeMap from", this);
}
}
},
remove: function () {
if (this.tagID) {
var nodeMap = this.getNodeMap();
if (nodeMap) {
removeDescendentsFromNodeMap(nodeMap, this);
}
}
var siblings = this.siblings;
var index = siblings.indexOf(this);
if (index > -1) {
siblings.splice(index, 1);
this.parent = null;
} else {
console.error("Unexpected attempt to remove (not in siblings)", this);
}
},
/**
* Search node by node up the tree until a nodeMap is found. Returns undefined
* if no nodeMap is found.
*/
getNodeMap: function () {
var elem = this,
nodeMap;
while (elem) {
nodeMap = elem.nodeMap;
if (nodeMap) {
break;
}
elem = elem.parent;
}
return nodeMap;
},
setAttribute: function (key, value) {
if (key === "data-brackets-id") {
this.tagID = value;
var nodeMap = this.getNodeMap();
if (nodeMap) {
nodeMap[key] = this;
} else {
console.error("no nodemap found for ", this);
}
}
this.attributes[key] = value;
},
removeAttribute: function (key) {
delete this.attributes[key];
},
returnFailure: function (other) {
console.log("TEST FAILURE AT TAG ID ", this.tagID, this, other);
console.log("Patched: ", HTMLSimpleDOM._dumpDOM(this.parent || this));
console.log("DOM generated from revised text: ", HTMLSimpleDOM._dumpDOM(other.parent || other));
return false;
},
/**
* Compares two SimpleDOMs with the expectation that they are exactly the same.
*/
compare: function (other) {
if (this.children) {
if (this.tag !== other.tag) {
expect("Tag " + this.tag + " for tagID " + this.tagID).toEqual(other.tag);
return this.returnFailure(other);
}
if (this.tagID !== other.tagID) {
expect("tagID " + this.tagID).toEqual(other.tagID);
return this.returnFailure(other);
}
delete this.attributes["data-brackets-id"];
expect(this.attributes).toEqual(other.attributes);
// Skip implied tags in this (fake browser) DOM. (The editor's DOM
// should never have implied tags.)
var myChildren = [];
this.children.forEach(function (child) {
var isImplied = (child.tag === "html" || child.tag === "head" || child.tag === "body") && child.tagID === undefined;
if (!isImplied) {
myChildren.push(child);
}
});
if (myChildren.length !== other.children.length) {
expect("tagID " + this.tagID + " has " + myChildren.length + " unimplied children").toEqual(other.children.length);
return this.returnFailure(other);
}
var i;
for (i = 0; i < myChildren.length; i++) {
if (!myChildren[i].compare(other.children[i])) {
return false;
}
}
} else {
if (this.content !== other.content) {
expect(this.content).toEqual(other.content);
return this.returnFailure(other);
}
}
return true;
}
});
/**
* Creates a deep clone of a SimpleDOM tree, adding the domFeatures as it goes
* along.
*
* @param {Object} root root node of the SimpleDOM to clone
* @return {Object} cloned SimpleDOM with domFeatures applied
*/
function cloneDOM(root) {
var nodeMap = {};
// If there's no DOM to clone, then we must be starting from an empty document,
// so start with a document that already has implied <html>/<head>/<body>, since
// that's what the browser does.
if (!root) {
root = createBlankDOM();
}
function doClone(parent, node) {
var newNode = Object.create(domFeatures);
newNode.parent = parent;
if (node.tagID) {
nodeMap[node.tagID] = newNode;
newNode.tagID = node.tagID;
}
newNode.content = node.content;
if (node.children) {
newNode.tag = node.tag;
newNode.attributes = $.extend({}, node.attributes);
newNode.children = node.children.map(function (child) {
return doClone(newNode, child);
});
} else {
newNode.content = node.content;
}
return newNode;
}
var newRoot = doClone(null, root);
newRoot.nodeMap = nodeMap;
return newRoot;
}
/**
* The RemoteFunctions code that applies edits to the DOM expects only a few things to
* be present on the document object. This FakeDocument bridges the gap between a
* SimpleDOM and real DOM for the purposes of applying edits.
*
* @param {Object} dom The DOM we're wrapping with this document.
*/
var FakeDocument = function (dom) {
var self = this;
this.dom = dom;
this.nodeMap = dom.nodeMap;
// Walk the DOM looking for html/head/body tags. We can't use the nodeMap for this
// because it might be nulled out in the cases where we're simulating the browser
// creating implicit html/head/body tags.
function walk(node) {
if (node.tag === "html") {
self.documentElement = node;
} else if (node.tag === "head" || node.tag === "body") {
self[node.tag] = node;
}
if (node.children) {
node.children.forEach(walk);
}
}
walk(dom);
};
// The DOM edit code only performs this kind of query
var bracketsIdQuery = /\[data-brackets-id='(\d+)'\]/;
FakeDocument.prototype = {
createTextNode: function (content) {
var text = Object.create(domFeatures);
text.content = content;
return text;
},
createElement: function (tag) {
var el = Object.create(domFeatures);
el.tag = tag;
el.attributes = {};
el.children = [];
el.nodeMap = this.nodeMap;
return el;
},
querySelectorAll: function (query) {
var match = bracketsIdQuery.exec(query);
expect(match).toBeTruthy();
if (!match) {
return [];
}
var id = match[1];
function walk(node) {
if (String(node.tagID) === id) {
return node;
}
if (node.children) {
var i, result;
for (i = 0; i < node.children.length; i++) {
result = walk(node.children[i]);
if (result) {
return result;
}
}
}
}
var element = walk(this.dom);
if (element) {
return [element];
}
}
};
describe("HTML Instrumentation", function () {
function getIdToTagMap(instrumentedHTML, map) {
var count = 0;
var elementIdRegEx = /<(\w+?)\s+(?:[^<]*?\s)*?data-brackets-id='(\S+?)'/gi,
match,
tagID,
tagName;
do {
match = elementIdRegEx.exec(instrumentedHTML);
if (match) {
tagID = match[2];
tagName = match[1];
// Verify that the newly found ID is unique.
expect(map[tagID]).toBeUndefined();
map[tagID] = tagName.toLowerCase();
count++;
}
} while (match);
return count;
}
function checkTagIdAtPos(pos, expectedTag) {
var tagID = HTMLInstrumentation._getTagIDAtDocumentPos(editor, pos);
if (!expectedTag) {
expect(tagID).toBe(-1);
} else {
expect(elementIds[tagID]).toBe(expectedTag);
}
}
function verifyMarksCreated() {
var cm = editor._codeMirror,
marks = cm.getAllMarks();
expect(marks.length).toBeGreaterThan(0);
}
describe("interaction with document and editor", function () {
beforeEach(function () {
HTMLInstrumentation._resetCache();
runs(function () {
editor = SpecRunnerUtils.createMockEditor(WellFormedDoc, "html").editor;
expect(editor).toBeTruthy();
});
});
it("should properly regenerate marks when instrumented HTML is re-requested after document is edited", function () {
runs(function () {
var instrumented = HTMLInstrumentation.generateInstrumentedHTML(editor);
getIdToTagMap(instrumented, elementIds);
checkTagIdAtPos({line: 12, ch: 1}, "h1");
editor.document.replaceRange("123456789012345678901234567890", {line: 12, ch: 0});
instrumented = HTMLInstrumentation.generateInstrumentedHTML(editor);
elementIds = {};
getIdToTagMap(instrumented, elementIds);
checkTagIdAtPos({line: 12, ch: 1}, "body");
checkTagIdAtPos({line: 12, ch: 31}, "h1");
var lines = instrumented.split("\n");
expect(lines[12]).toMatch(/^123456789012345678901234567890<h1 data-brackets-id='[0-9]+'>GETTING STARTED WITH BRACKETS<\/h1>$/);
});
});
});
describe("HTML Instrumentation in wellformed HTML", function () {
beforeEach(function () {
runs(function () {
editor = SpecRunnerUtils.createMockEditor(WellFormedDoc, "html").editor;
expect(editor).toBeTruthy();
spyOn(editor.document, "getText").andCallThrough();
instrumentedHTML = HTMLInstrumentation.generateInstrumentedHTML(editor);
elementCount = getIdToTagMap(instrumentedHTML, elementIds);
if (elementCount) {
HTMLInstrumentation._markText(editor);
verifyMarksCreated();
}
});
});
afterEach(function () {
SpecRunnerUtils.destroyMockEditor(editor.document);
editor = null;
instrumentedHTML = "";
elementCount = 0;
elementIds = {};
});
it("should instrument all start tags except some empty tags", function () {
runs(function () {
expect(elementCount).toEqual(15);
});
});
it("should have created cache and never call document.getText() again", function () {
runs(function () {
// scanDocument call here is to test the cache.
// HTMLInstrumentation.generateInstrumentedHTML call in "beforeEach"
// in turn calls scanDocument. Each function calls document.getText once
// and hence we've already had 2 calls from "beforeEach", but the following
// call should not call it again.
HTMLInstrumentation.scanDocument(editor.document);
expect(editor.document.getText.callCount).toBe(2);
});
});
it("should have recreated cache when document timestamp is different", function () {
runs(function () {
// update document timestamp with current time.
editor.document.diskTimestamp = new Date();
// This is an intentional repeat call to recreate the cache.
HTMLInstrumentation.scanDocument(editor.document);
// 2 calls from generateInstrumentedHTML call and one call
// from above scanDocument call. so total is 3.
expect(editor.document.getText.callCount).toBe(3);
});
});
it("should get 'img' tag for cursor positions inside img tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 37, ch: 4 }, "img"); // before <img
checkTagIdAtPos({ line: 37, ch: 95 }, "img"); // after />
checkTagIdAtPos({ line: 37, ch: 65 }, "img"); // inside src attribute value
});
});
it("should get the parent 'a' tag for cursor positions between 'img' and its parent 'a' tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 37, ch: 1 }, "a"); // before " <img"
checkTagIdAtPos({ line: 38, ch: 0 }, "a"); // before </a>
});
});
it("No tag at cursor positions outside of the 'html' tag", function () {
runs(function () {
checkTagIdAtPos({ line: 0, ch: 4 }, ""); // inside 'doctype' tag
checkTagIdAtPos({ line: 41, ch: 0 }, ""); // after </html>
});
});
it("Should get parent tag (body) for all cursor positions inside an html comment", function () {
runs(function () {
checkTagIdAtPos({ line: 15, ch: 1 }, "body"); // cursor between < and ! in the comment start
checkTagIdAtPos({ line: 16, ch: 15 }, "body");
checkTagIdAtPos({ line: 17, ch: 3 }, "body"); // cursor after -->
});
});
it("should get 'meta/link' tag for cursor positions in meta/link tags, not 'head' tag", function () {
runs(function () {
checkTagIdAtPos({ line: 5, ch: 64 }, "meta");
checkTagIdAtPos({ line: 8, ch: 12 }, "link");
});
});
it("Should get 'title' tag at cursor positions (either in the content or begin/end tag)", function () {
runs(function () {
checkTagIdAtPos({ line: 6, ch: 11 }, "title"); // inside the begin tag
checkTagIdAtPos({ line: 6, ch: 30 }, "title"); // in the content
checkTagIdAtPos({ line: 6, ch: 50 }, "title"); // inside the end tag
});
});
it("Should get 'h2' tag at cursor positions (either in the content or begin or end tag)", function () {
runs(function () {
checkTagIdAtPos({ line: 13, ch: 1 }, "h2"); // inside the begin tag
checkTagIdAtPos({ line: 13, ch: 20 }, "h2"); // in the content
checkTagIdAtPos({ line: 13, ch: 27 }, "h2"); // inside the end tag
});
});
});
describe("HTML Instrumentation in valid but not wellformed HTML", function () {
beforeEach(function () {
runs(function () {
editor = SpecRunnerUtils.createMockEditor(NotWellFormedDoc, "html").editor;
expect(editor).toBeTruthy();
instrumentedHTML = HTMLInstrumentation.generateInstrumentedHTML(editor);
elementCount = getIdToTagMap(instrumentedHTML, elementIds);
if (elementCount) {
HTMLInstrumentation._markText(editor);
verifyMarksCreated();
}
});
});
afterEach(function () {
SpecRunnerUtils.destroyMockEditor(editor.document);
editor = null;
instrumentedHTML = "";
elementCount = 0;
elementIds = {};
});
it("should instrument all start tags except some empty tags", function () {
runs(function () {
expect(elementCount).toEqual(43);
});
});
it("should get 'p' tag for cursor positions before the succeding start tag of an unclosed 'p' tag", function () {
runs(function () {
checkTagIdAtPos({ line: 8, ch: 36 }, "p"); // at the end of the line that has p start tag
checkTagIdAtPos({ line: 8, ch: 2 }, "p"); // at the beginning of the <p>
checkTagIdAtPos({ line: 8, ch: 4 }, "p"); // inside <p> tag
checkTagIdAtPos({ line: 8, ch: 5 }, "p"); // after <p> tag
checkTagIdAtPos({ line: 9, ch: 0 }, "p"); // before <h1> tag, but considered to be the end of 'p' tag
});
});
it("should get 'h1' tag for cursor positions inside 'h1' that is following an unclosed 'p' tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 9, ch: 20 }, "h1"); // inside text content of h1 tag
checkTagIdAtPos({ line: 9, ch: 52 }, "h1"); // inside </h1>
});
});
it("should get 'wbr' tag for cursor positions inside <wbr>, not its parent 'h1' tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 9, ch: 10 }, "wbr"); // inside <wbr> that is in h1 content
});
});
it("should get 'li' tag for cursor positions inside the content of an unclosed 'li' tag", function () {
runs(function () {
checkTagIdAtPos({ line: 12, ch: 12 }, "li"); // inside first list item
checkTagIdAtPos({ line: 14, ch: 12 }, "li"); // inside third list item
checkTagIdAtPos({ line: 15, ch: 0 }, "li"); // before </ul> tag that follows an unclosed 'li'
});
});
it("should get 'br' tag for cursor positions inside <br>, not its parent 'li' tag", function () {
runs(function () {
checkTagIdAtPos({ line: 13, ch: 22 }, "br"); // inside the <br> tag of the second list item
});
});
it("should get 'ul' tag for cursor positions within 'ul' but outside of any unclosed 'li'.", function () {
runs(function () {
checkTagIdAtPos({ line: 12, ch: 0 }, "ul"); // before first '<li>' tag
checkTagIdAtPos({ line: 15, ch: 8 }, "ul"); // inside </ul>
});
});
it("should get 'table' tag for cursor positions that are not in any unclosed child tags", function () {
runs(function () {
checkTagIdAtPos({ line: 17, ch: 17 }, "table"); // inside an attribute of table tag
checkTagIdAtPos({ line: 32, ch: 6 }, "table"); // inside </table> tag
});
});
it("should get 'tr' tag for cursor positions between child tags", function () {
runs(function () {
checkTagIdAtPos({ line: 21, ch: 0 }, "tr"); // after a 'th' but before the start tag of another one
});
});
it("should get 'input' tag for cursor positions inside one of the 'input' tags.", function () {
runs(function () {
checkTagIdAtPos({ line: 34, ch: 61 }, "input"); // at the end of first input tag
checkTagIdAtPos({ line: 35, ch: 4 }, "input"); // at the first position of the 2nd input tag
});
});
it("should get 'option' tag for cursor positions in any unclosed 'option' tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 40, ch: 0 }, "option"); // before second '<option>' tag
checkTagIdAtPos({ line: 41, ch: 28 }, "option"); // after third option tag that is unclosed
});
});
it("should NOT get 'option' tag for cursor positions in the parent tags of an unclosed 'option'.", function () {
runs(function () {
checkTagIdAtPos({ line: 42, ch: 5 }, "select"); // inside '</select>' tag
checkTagIdAtPos({ line: 43, ch: 5 }, "form"); // inside '</form>' tag
});
});
it("should get 'label' tag for cursor positions in the 'label' tag or its content.", function () {
runs(function () {
checkTagIdAtPos({ line: 37, ch: 17 }, "label"); // in the attribute of 'label' tag
checkTagIdAtPos({ line: 37, ch: 49 }, "label"); // in the text content
checkTagIdAtPos({ line: 37, ch: 55 }, "label"); // in the end 'label' tag
});
});
it("should get 'form' tag for cursor positions NOT in any form element.", function () {
runs(function () {
checkTagIdAtPos({ line: 35, ch: 0 }, "form"); // between two input tags
checkTagIdAtPos({ line: 43, ch: 2 }, "form"); // before </form> tag
});
});
it("should get 'hr' tag for cursor positions in <hr> tag, not its parent <form> tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 36, ch: 6 }, "hr"); // inside <hr>
});
});
it("should get 'script' tag for cursor positions anywhere inside the tag including CDATA.", function () {
runs(function () {
checkTagIdAtPos({ line: 46, ch: 6 }, "script"); // before '<' of CDATA
checkTagIdAtPos({ line: 48, ch: 7 }, "script"); // right before '>' of CDATA
checkTagIdAtPos({ line: 45, ch: 18 }, "script"); // inside an attribute value of 'script' tag
checkTagIdAtPos({ line: 47, ch: 20 }, "script"); // before '<' of a literal string
checkTagIdAtPos({ line: 49, ch: 9 }, "script"); // inside 'script' end tag
});
});
it("should get 'footer' tag that is explicitly using all uppercase tag names.", function () {
runs(function () {
checkTagIdAtPos({ line: 50, ch: 3 }, "footer"); // in <FOOTER>
checkTagIdAtPos({ line: 50, ch: 20 }, "footer"); // in the text content
checkTagIdAtPos({ line: 50, ch: 30 }, "footer"); // in </FOOTER>
});
});
it("should get 'body' for text after an h1 that closed a previous uncleosd paragraph", function () {
runs(function () {
checkTagIdAtPos({ line: 53, ch: 2 }, "body"); // in the text content after the h1
});
});
});
describe("HTML Instrumentation in an HTML page with some invalid markups", function () {
beforeEach(function () {
runs(function () {
editor = SpecRunnerUtils.createMockEditor(InvalidHTMLDoc, "html").editor;
expect(editor).toBeTruthy();
instrumentedHTML = HTMLInstrumentation.generateInstrumentedHTML(editor);
elementCount = getIdToTagMap(instrumentedHTML, elementIds);
if (elementCount) {
HTMLInstrumentation._markText(editor);
verifyMarksCreated();
}
});
});
afterEach(function () {
SpecRunnerUtils.destroyMockEditor(editor.document);
editor = null;
instrumentedHTML = "";
elementCount = 0;
elementIds = {};
});
it("should instrument all start tags except some empty tags", function () {
runs(function () {
expect(elementCount).toEqual(39);
});
});
it("should get 'script' tag for cursor positions anywhere inside the tag including CDATA.", function () {
runs(function () {
checkTagIdAtPos({ line: 6, ch: 11 }, "script"); // before '<' of CDATA
checkTagIdAtPos({ line: 8, ch: 12 }, "script"); // right before '>' of CDATA
checkTagIdAtPos({ line: 5, ch: 33 }, "script"); // inside an attribute value of 'script' tag
checkTagIdAtPos({ line: 7, ch: 25 }, "script"); // after '<' of a literal string
checkTagIdAtPos({ line: 9, ch: 9 }, "script"); // inside 'script' end tag
});
});
it("should get 'style' tag for cursor positions anywhere inside the tag including CDATA.", function () {
runs(function () {
checkTagIdAtPos({ line: 11, ch: 11 }, "style"); // before '<' of CDATA
checkTagIdAtPos({ line: 13, ch: 12 }, "style"); // right before '>' of CDATA
checkTagIdAtPos({ line: 10, ch: 26 }, "style"); // before '>' of the 'style' tag
checkTagIdAtPos({ line: 12, ch: 33 }, "style"); // inside a property value
checkTagIdAtPos({ line: 14, ch: 9 }, "style"); // inside 'style' end tag
});
});
it("should get 'i' tag for cursor position before </b>.", function () {
runs(function () {
checkTagIdAtPos({ line: 18, ch: 20 }, "i"); // after <i> and before </b>
checkTagIdAtPos({ line: 18, ch: 28 }, "i"); // immediately before </b>
});
});
it("should get 'p' tag after </b> because the </b> closed the overlapping <i>.", function () {
runs(function () {
checkTagIdAtPos({ line: 18, ch: 34 }, "p"); // between </b> and </i>
});
});
it("should get 'body' tag in a paragraph that has missing <p> tag, but has </p>", function () {
runs(function () {
checkTagIdAtPos({ line: 19, ch: 15 }, "body"); // before </p>
checkTagIdAtPos({ line: 19, ch: 38 }, "body"); // inside </p>
});
});
it("should get 'hr' tag for cursor positions in any forms of <hr> tag", function () {
runs(function () {
checkTagIdAtPos({ line: 48, ch: 7 }, "hr"); // inside <hr>
checkTagIdAtPos({ line: 50, ch: 9 }, "hr"); // inside <hr />
});
});
it("should get 'h2' tag for cursor positions between <wbr> and its invalide end tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 20, ch: 35 }, "h2"); // in the text between <wbr> and </wbr>
});
});
it("should get 'wbr' tag for cursor positions inside <wbr>, not its parent <h2> tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 20, ch: 30 }, "wbr"); // inside <wbr>
});
});
it("should get 'h2' tag for cursor positions inside invalid </wbr> tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 20, ch: 40 }, "h2"); // inside </wbr>
});
});
it("should get 'name' tag for cursor positions before <name> and </name>.", function () {
runs(function () {
checkTagIdAtPos({ line: 21, ch: 8 }, "name"); // inside <name>
checkTagIdAtPos({ line: 21, ch: 12 }, "name"); // inside content of 'mame' tag
checkTagIdAtPos({ line: 21, ch: 22 }, "name"); // inside </name>
});
});
it("should get 'th' tag for cursor positions in any 'th' and their text contents.", function () {
runs(function () {
checkTagIdAtPos({ line: 24, ch: 16 }, "th"); // inside first th content
checkTagIdAtPos({ line: 25, ch: 21 }, "th"); // inside second </th>
checkTagIdAtPos({ line: 26, ch: 17 }, "th"); // at the end of third th content
checkTagIdAtPos({ line: 27, ch: 0 }, "th"); // before the next <tr>
});
});
it("should get 'input' tag for cursor positions in any input tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 39, ch: 57 }, "input"); // inside value attribute that has <
checkTagIdAtPos({ line: 39, ch: 64 }, "input"); // between / and > of input tag
checkTagIdAtPos({ line: 40, ch: 61 }, "input"); // inside value attribute that has >
checkTagIdAtPos({ line: 40, ch: 63 }, "input"); // right before the invalid </input>
});
});
it("should get 'form' tag for cursor positions in any invalid end tag inside the form.", function () {
runs(function () {
checkTagIdAtPos({ line: 40, ch: 65 }, "form"); // inside </input>
});
});
it("should get 'p' tag for cursor positions inside an unclosed paragraph nested in a link.", function () {
runs(function () {
checkTagIdAtPos({ line: 49, ch: 71 }, "p"); // before </a> but after <p> tag
});
});
it("should get 'a' tag for cursor positions not in the unclosed 'p' child tag.", function () {
runs(function () {
checkTagIdAtPos({ line: 49, ch: 32 }, "a"); // inside </a>
checkTagIdAtPos({ line: 49, ch: 72 }, "a"); // inside </a>
});
});
});
// Log useful information when debugging a test.
function debuggingDump(result, previousDOM) {
console.log("Old DOM", HTMLSimpleDOM._dumpDOM(previousDOM));
console.log("New DOM", HTMLSimpleDOM._dumpDOM(result.dom));
console.log("Edits", JSON.stringify(result.edits, null, 2));
}
// Workaround for JSHint to not complain about the unused function
void(debuggingDump);
describe("HTML Instrumentation in dirty files", function () {
var changeList, offsets;
function setupEditor(docText, useOffsets) {
runs(function () {
if (useOffsets) {
var result = SpecRunnerUtils.parseOffsetsFromText(docText);
docText = result.text;
offsets = result.offsets;
}
editor = SpecRunnerUtils.createMockEditor(docText, "html").editor;
expect(editor).toBeTruthy();
editor.on("change.instrtest", function (event, editor, change) {
changeList = change;
});
instrumentedHTML = HTMLInstrumentation.generateInstrumentedHTML(editor);
elementCount = getIdToTagMap(instrumentedHTML, elementIds);
});
}
function checkMarkSanity() {
// Ensure that we don't have multiple marks for the same tagID.
var marks = editor._codeMirror.getAllMarks(),
foundMarks = {};
marks.forEach(function (mark) {
if (mark.hasOwnProperty("tagID")) {
if (foundMarks[mark.tagID]) {
expect("mark with ID " + mark.tagID).toBe("unique");
}
foundMarks[mark.tagID] = true;
}
});
}
afterEach(function () {
SpecRunnerUtils.destroyMockEditor(editor.document);
editor = null;
instrumentedHTML = "";
elementCount = 0;
elementIds = {};
changeList = null;
offsets = null;
});
function doEditTest(origText, editFn, expectationFn, incremental, noRefresh) {
// We need to fully reset the editor/mark state between the full and incremental tests
// because if new DOM nodes are added by the edit, those marks will be present after the
// full test, messing up the incremental test.
if (!noRefresh) {
editor.document.refreshText(origText);
}
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
var clonedDOM = cloneDOM(previousDOM);
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
editFn(editor, previousDOM);
// Note that even if we pass a change list, `_updateDOM` will still choose to do a
// full reparse and diff if the change includes a structural character.
result = HTMLInstrumentation._updateDOM(previousDOM, editor, (incremental ? changeList : null));
checkMarkSanity();
var doc = new FakeDocument(clonedDOM);
var editHandler = new RemoteFunctions.DOMEditHandler(doc);
editHandler.apply(result.edits);
clonedDOM.compare(result.dom);
expectationFn(result, previousDOM, incremental);
}
function doFullAndIncrementalEditTest(editFn, expectationFn) {
var origText = editor.document.getText();
doEditTest(origText, editFn, expectationFn, false);
changeList = null;
if (HTMLInstrumentation._allowIncremental) {
doEditTest(origText, editFn, expectationFn, true);
}
}
// Common functionality between typeAndExpect() and deleteAndExpect().
function doOperationAndExpect(editor, curDOM, pos, edits, wasInvalid, numIterations, operationFn, posUpdateFn) {
var i, result, clonedDOM;
for (i = 0; i < numIterations; i++) {
clonedDOM = cloneDOM(curDOM);
operationFn(i, pos);
result = HTMLInstrumentation._updateDOM(curDOM, editor, wasInvalid ? null : changeList);
if (!edits) {
expect(Array.isArray(result.errors)).toBe(true);
wasInvalid = true;
} else {
var expectedEdit = edits[i];
if (typeof expectedEdit === "function") {
// This lets the caller access the most recent updated DOM values when
// specifying the expected edit.
expectedEdit = expectedEdit(result.dom);
}
expect(result.edits).toEqual(expectedEdit);
wasInvalid = false;
var doc = new FakeDocument(clonedDOM);
var editHandler = new RemoteFunctions.DOMEditHandler(doc);
editHandler.apply(result.edits);
clonedDOM.compare(result.dom);
checkMarkSanity();
curDOM = result.dom;
}
posUpdateFn(pos);
}
return {finalDOM: curDOM, finalPos: pos, finalInvalid: wasInvalid};
}
/*
* Simulates typing the given string character by character. If edits is specified, then
* each successive character is expected to generate the edits at that position in the array.
* If edits is unspecified, then the document is expected to be in an invalid state at each
* step, so no edits should be generated.
* Returns the final DOM after all the edits, or the original DOM if the document is in an invalid state.
*/
function typeAndExpect(editor, curDOM, pos, str, edits, wasInvalid) {
return doOperationAndExpect(editor, curDOM, pos, edits, wasInvalid,
str.length,
function (i, pos) {
editor.document.replaceRange(str.charAt(i), pos);
},
function (pos) {
pos.ch++;
});
}
/*
* Simulates deleting the specified number of characters one at a time. If edits is specified, then
* each successive character is expected to generate the edits at that position in the array.
* If edits is unspecified, then the document is expected to be in an invalid state at each
* step, so no edits should be generated.
* Returns the final DOM after all the edits, or the original DOM if the document is in an invalid state.
*/
function deleteAndExpect(editor, curDOM, pos, numToDelete, edits, wasInvalid) {
return doOperationAndExpect(editor, curDOM, pos, edits, wasInvalid,
numToDelete,
function (i, pos) {
editor.document.replaceRange("", {line: pos.line, ch: pos.ch - 1}, pos);
},
function (pos) {
pos.ch--;
});
}
it("should re-instrument after document is dirtied", function () {
setupEditor(WellFormedDoc);
runs(function () {
var pos = {line: 15, ch: 0};
editor.document.replaceRange("<div>New Content</div>", pos);
var newInstrumentedHTML = HTMLInstrumentation.generateInstrumentedHTML(editor),
newElementIds = {},
newElementCount = getIdToTagMap(newInstrumentedHTML, newElementIds);
expect(newElementCount).toBe(elementCount + 1);
});
});
it("should mark editor text based on the simple DOM", function () {
setupEditor(WellFormedDoc);
runs(function () {
var dom = HTMLSimpleDOM.build(editor.document.getText());
HTMLInstrumentation._markTextFromDOM(editor, dom);
expect(editor._codeMirror.getAllMarks().length).toEqual(15);
});
});
it("should handle no diff", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText());
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
var result = HTMLInstrumentation._updateDOM(previousDOM, editor);
expect(result.edits).toEqual([]);
expect(result.dom).toEqual(previousDOM);
});
});
it("should handle attribute change", function () {
setupEditor(WellFormedDoc);
runs(function () {
var tagID, origParent;
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange(", awesome", { line: 7, ch: 56 });
tagID = previousDOM.children[1].children[7].tagID;
origParent = previousDOM.children[1];
},
function (result, previousDOM, incremental) {
expect(result.edits.length).toEqual(1);
expect(result.edits[0]).toEqual({
type: "attrChange",
tagID: tagID,
attribute: "content",
value: "An interactive, awesome getting started guide for Brackets."
});
if (incremental) {
// this should have been a true incremental edit
expect(result._wasIncremental).toBe(true);
// make sure the parent of the change is still the same node as in the old tree
expect(result.dom.nodeMap[tagID].parent).toBe(origParent);
} else {
// entire tree should be different
expect(result.dom.nodeMap[tagID].parent).not.toBe(origParent);
}
}
);
});
});
it("should handle new attributes", function () {
setupEditor(WellFormedDoc);
runs(function () {
var tagID, origParent;
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange(" class='supertitle'", { line: 12, ch: 3 });
tagID = previousDOM.children[3].children[1].tagID;
origParent = previousDOM.children[3];
},
function (result, previousDOM, incremental) {
expect(result.edits.length).toEqual(1);
expect(result.edits[0]).toEqual({
type: "attrAdd",
tagID: tagID,
attribute: "class",
value: "supertitle"
});
if (incremental) {
// this should not have been a true incremental edit since it changed the attribute structure
expect(result._wasIncremental).toBe(false);
}
// entire tree should be different
expect(result.dom.nodeMap[tagID].parent).not.toBe(origParent);
}
);
});
});
it("should handle deleted attributes", function () {
setupEditor(WellFormedDoc);
runs(function () {
var tagID, origParent;
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("", {line: 7, ch: 32}, {line: 7, ch: 93});
tagID = previousDOM.children[1].children[7].tagID;
origParent = previousDOM.children[1];
},
function (result, previousDOM, incremental) {
expect(result.edits.length).toEqual(1);
expect(result.edits[0]).toEqual({
type: "attrDelete",
tagID: tagID,
attribute: "content"
});
if (incremental) {
// this should not have been a true incremental edit since it changed the attribute structure
expect(result._wasIncremental).toBe(false);
}
// entire tree should be different
expect(result.dom.nodeMap[tagID].parent).not.toBe(origParent);
}
);
});
});
it("should handle simple altered text", function () {
setupEditor(WellFormedDoc);
runs(function () {
var tagID, origParent;
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("AWESOMER", {line: 12, ch: 12}, {line: 12, ch: 19});
tagID = previousDOM.children[3].children[1].tagID;
origParent = previousDOM.children[3];
},
function (result, previousDOM, incremental) {
expect(result.edits.length).toEqual(1);
expect(previousDOM.children[3].children[1].tag).toEqual("h1");
expect(result.edits[0]).toEqual({
type: "textReplace",
parentID: tagID,
content: "GETTING AWESOMER WITH BRACKETS"
});
if (incremental) {
// this should have been an incremental edit since it was just typing
expect(result._wasIncremental).toBe(true);
// make sure the parent of the change is still the same node as in the old tree
expect(result.dom.nodeMap[tagID].parent).toBe(origParent);
} else {
// entire tree should be different
expect(result.dom.nodeMap[tagID].parent).not.toBe(origParent);
}
}
);
});
});
it("should handle two incremental text edits in a row", function () {
// Short-circuit this test if we're running without incremental updates
if (!HTMLInstrumentation._allowIncremental) {
return;
}
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
tagID = previousDOM.children[3].children[1].tagID,
result,
origParent = previousDOM.children[3];
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
editor.document.replaceRange("AWESOMER", {line: 12, ch: 12}, {line: 12, ch: 19});
result = HTMLInstrumentation._updateDOM(previousDOM, editor, changeList);
// TODO: how to test that only an appropriate subtree was reparsed/diffed?
expect(result.edits.length).toEqual(1);
expect(result.dom.children[3].children[1].tag).toEqual("h1");
expect(result.dom.children[3].children[1].tagID).toEqual(tagID);
expect(result.edits[0]).toEqual({
type: "textReplace",
parentID: tagID,
content: "GETTING AWESOMER WITH BRACKETS"
});
// this should have been an incremental edit since it was just typing
expect(result._wasIncremental).toBe(true);
// make sure the parent of the change is still the same node as in the old tree
expect(result.dom.nodeMap[tagID].parent).toBe(origParent);
editor.document.replaceRange("MOAR AWESOME", {line: 12, ch: 12}, {line: 12, ch: 20});
result = HTMLInstrumentation._updateDOM(previousDOM, editor, changeList);
// TODO: how to test that only an appropriate subtree was reparsed/diffed?
expect(result.edits.length).toEqual(1);
expect(result.dom.children[3].children[1].tag).toEqual("h1");
expect(result.dom.children[3].children[1].tagID).toEqual(tagID);
expect(result.edits[0]).toEqual({
type: "textReplace",
parentID: tagID,
content: "GETTING MOAR AWESOME WITH BRACKETS"
});
// this should have been an incremental edit since it was just typing
expect(result._wasIncremental).toBe(true);
// make sure the parent of the change is still the same node as in the old tree
expect(result.dom.nodeMap[tagID].parent).toBe(origParent);
});
});
it("should avoid updating while typing an incomplete tag, then update when it's done", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// While the tag is incomplete, we should get no edits.
result = typeAndExpect(editor, previousDOM, {line: 12, ch: 38}, "<p");
expect(result.finalInvalid).toBe(true);
// This simulates our autocomplete behavior. The next case simulates the non-autocomplete case.
editor.document.replaceRange("></p>", {line: 12, ch: 40});
// We don't pass the changeList here, to simulate doing a full rebuild (which is
// what the normal incremental update logic would do after invalid edits).
// TODO: a little weird that we're not going through the normal update logic
// (in getUnappliedEditList, etc.)
result = HTMLInstrumentation._updateDOM(previousDOM, editor);
// This should really only have one edit (the tag insertion), but it also
// deletes and recreates the whitespace after it, similar to other insert cases.
var newElement = result.dom.children[3].children[2],
parentID = newElement.parent.tagID,
afterID = result.dom.children[3].children[1].tagID,
beforeID = result.dom.children[3].children[4].tagID;
expect(result.edits.length).toEqual(3);
expect(newElement.tag).toEqual("p");
expect(result.edits[0]).toEqual({
type: "textDelete",
parentID: parentID,
afterID: afterID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "p",
tagID: newElement.tagID,
attributes: {},
parentID: parentID,
beforeID: beforeID // TODO: why is there no afterID here?
});
expect(result.edits[2]).toEqual({
type: "textInsert",
content: "\n",
parentID: parentID,
afterID: newElement.tagID,
beforeID: beforeID
});
});
});
it("should handle typing of a <p> without a </p> and then adding it later", function () {
setupEditor(WellFormedDoc);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// No edits should occur while we're invalid.
result = typeAndExpect(editor, previousDOM, {line: 12, ch: 38}, "<p");
expect(result.finalInvalid).toBe(true);
// This simulates what would happen if autocomplete were off. We're actually
// valid at this point since <p> is implied close. We want to make sure that
// basically nothing happens if the user types </p> after this.
editor.document.replaceRange(">", {line: 12, ch: 40});
// We don't pass the changeList here, to simulate doing a full rebuild (which is
// what the normal incremental update logic would do after invalid edits).
// TODO: a little weird that we're not going through the normal update logic
// (in getUnappliedEditList, etc.)
result = HTMLInstrumentation._updateDOM(previousDOM, editor);
// Since the <p> is unclosed, we think the whitespace after it is inside it.
var newElement = result.dom.children[3].children[2],
parentID = newElement.parent.tagID,
afterID = result.dom.children[3].children[1].tagID,
beforeID = result.dom.children[3].children[3].tagID;
expect(result.edits.length).toEqual(3);
expect(newElement.tag).toEqual("p");
expect(newElement.children.length).toEqual(1);
expect(newElement.children[0].content).toEqual("\n");
expect(result.edits[0]).toEqual({
type: "textDelete",
parentID: parentID,
afterID: afterID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "p",
tagID: newElement.tagID,
attributes: {},
parentID: parentID,
beforeID: beforeID // No afterID because beforeID is preferred given the insertBefore DOM API
});
expect(result.edits[2]).toEqual({
type: "textInsert",
content: "\n",
parentID: newElement.tagID,
lastChild: true
});
// We should get no edits while typing the close tag.
previousDOM = result.dom;
result = typeAndExpect(editor, previousDOM, {line: 12, ch: 41}, "</p");
expect(result.finalInvalid).toBe(true);
// When we type the ">" at the end, we should get a delete of the text inside the <p>
// and an insert of text after the </p> since we now know that the close is before the
// text.
editor.document.replaceRange(">", {line: 12, ch: 44});
result = HTMLInstrumentation._updateDOM(previousDOM, editor);
newElement = result.dom.children[3].children[2];
beforeID = result.dom.children[3].children[4].tagID;
expect(newElement.children.length).toEqual(0);
expect(result.dom.children[3].children[3].content).toEqual("\n");
expect(result.edits.length).toEqual(2);
expect(result.edits[0]).toEqual({
type: "textInsert",
content: "\n",
parentID: newElement.parent.tagID,
afterID: newElement.tagID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "textDelete",
parentID: newElement.tagID
});
});
});
it("should handle deleting of an empty tag character-by-character", function () {
setupEditor("<p><img>{{0}}</p>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
imgTagID = previousDOM.children[0].tagID,
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// First four deletions should keep it in an invalid state.
result = deleteAndExpect(editor, previousDOM, offsets[0], 4);
expect(result.finalInvalid).toBe(true);
// We're exiting an invalid state, so we pass "true" for the final argument
// here, which forces a full reparse (the same as getUnappliedEdits() does).
deleteAndExpect(editor, result.finalDOM, result.finalPos, 1, [
[{type: "elementDelete", tagID: imgTagID}]
], true);
});
});
it("should handle deleting of a non-empty tag character-by-character", function () {
setupEditor("<div><b>deleteme</b>{{0}}</div>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
pTagID = previousDOM.children[0].tagID,
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// All the deletions until we get to the "<" should leave the document in an invalid state.
result = deleteAndExpect(editor, previousDOM, offsets[0], 14);
expect(result.finalInvalid).toBe(true);
// We're exiting an invalid state, so we pass "true" for the final argument
// here, which forces a full reparse (the same as getUnappliedEdits() does).
deleteAndExpect(editor, result.finalDOM, result.finalPos, 1, [
[{type: "elementDelete", tagID: pTagID}]
], true);
});
});
it("should handle deleting of a single character exactly between two elements", function () {
setupEditor("<p><br>X{{0}}<br></p>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
pTagID = previousDOM.tagID,
br1TagID = previousDOM.children[0].tagID,
br2TagID = previousDOM.children[2].tagID;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
deleteAndExpect(editor, previousDOM, offsets[0], 1, [
[{type: 'textDelete', parentID: pTagID, afterID: br1TagID, beforeID: br2TagID}]
]);
});
});
it("should handle typing of a new attribute character-by-character", function () {
setupEditor("<p{{0}}>some text</p>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
tagID = previousDOM.tagID,
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// Type a space after the tag name, then the attribute name. After the space,
// it should be valid but there should be no actual edits. After that, it should
// look like we're repeatedly adding a new empty attribute and deleting the old one.
// edits to be generated.
result = typeAndExpect(editor, previousDOM, offsets[0], " class", [
[], // " "
[ // " c"
{type: "attrAdd", tagID: tagID, attribute: "c", value: ""}
],
[ // " cl"
{type: "attrAdd", tagID: tagID, attribute: "cl", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "c"}
],
[ // " cla"
{type: "attrAdd", tagID: tagID, attribute: "cla", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "cl"}
],
[ // " clas"
{type: "attrAdd", tagID: tagID, attribute: "clas", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "cla"}
],
[ // " class"
{type: "attrAdd", tagID: tagID, attribute: "class", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "clas"}
]
]);
// While typing the "=" and quoted value, nothing should happen until the quote is balanced.
result = typeAndExpect(editor, result.finalDOM, result.finalPos, "='myclass");
expect(result.finalInvalid).toBe(true);
// We're exiting an invalid state, so we pass "true" for the final argument
// here, which forces a full reparse (the same as getUnappliedEdits() does).
// When the close quote is typed, we should get an attribute change.
typeAndExpect(editor, result.finalDOM, result.finalPos, "'", [
[
{type: "attrChange", tagID: tagID, attribute: "class", value: "myclass"}
]
], true);
});
});
it("should handle deleting of an attribute character-by-character", function () {
setupEditor("<p class='myclass'{{0}}>some text</p>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
tagID = previousDOM.tagID,
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// Delete the attribute value starting from the end quote. We should be invalid until
// we delete the = sign.
result = deleteAndExpect(editor, previousDOM, offsets[0], 9);
expect(result.finalInvalid).toBe(true);
// We're exiting an invalid state, so we pass "true" for the final argument
// here, which forces a full reparse (the same as getUnappliedEdits() does)
// for the first edit.
// Delete the = sign, then the name, then the space. This should look like
// setting the value to "", then changing the attribute name, then an empty edit.
deleteAndExpect(editor, result.finalDOM, result.finalPos, 6, [
[ // " class"
{type: "attrChange", tagID: tagID, attribute: "class", value: ""}
],
[ // " clas"
{type: "attrAdd", tagID: tagID, attribute: "clas", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "class"}
],
[ // " cla"
{type: "attrAdd", tagID: tagID, attribute: "cla", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "clas"}
],
[ // " cl"
{type: "attrAdd", tagID: tagID, attribute: "cl", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "cla"}
],
[ // " c"
{type: "attrAdd", tagID: tagID, attribute: "c", value: ""},
{type: "attrDelete", tagID: tagID, attribute: "cl"}
],
[ // " "
{type: "attrDelete", tagID: tagID, attribute: "c"}
],
[] // deletion of space
], true);
});
});
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 = HTMLSimpleDOM.build(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// Type the opening tag--should be invalid all the way
result = typeAndExpect(editor, previousDOM, offsets[0], "<span>");
expect(result.finalInvalid).toBe(true);
// Type the end tag--should be invalid until we type the closing character
// The offset is 6 characters later than the original position of offset 1 since we
// inserted the opening tag.
result = typeAndExpect(editor, result.finalDOM, {line: offsets[1].line, ch: offsets[1].ch + 6}, "</span", null, true);
expect(result.finalInvalid).toBe(true);
// Finally become valid by closing the end tag.
typeAndExpect(editor, result.finalDOM, result.finalPos, ">", [
function (dom) { // check for tagIDs relative to the DOM after typing
return [
{
type: "textDelete",
parentID: dom.tagID
},
{
type: "elementInsert",
tag: "span",
attributes: {},
tagID: dom.children[0].tagID,
parentID: dom.tagID,
lastChild: true
},
{
type: "textInsert",
parentID: dom.children[0].tagID,
content: "some text",
lastChild: true
}
];
}
], true); // because we were invalid before this operation
});
});
it("should handle adding an <html> tag into an empty document", function () {
setupEditor("");
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
// Nothing to mark since it's currently an empty document.
expect(previousDOM).toBe(null);
// Type the opening tag--should be invalid all the way
result = typeAndExpect(editor, previousDOM, {line: 0, ch: 0}, "<html");
expect(result.finalInvalid).toBe(true);
// Finally become valid by closing the start tag. Note that this elementInsert
// should be treated specially by RemoteFunctions not to actually insert the
// element, but just copy its ID to the autocreated HTML element.
result = typeAndExpect(editor, result.finalDOM, result.finalPos, ">", [
function (dom) { // check for tagIDs relative to the DOM after typing
return [
{
type: "elementInsert",
tag: "html",
attributes: {},
tagID: dom.tagID,
parentID: null
}
];
}
], true); // because we were invalid before this operation
// Make sure the mark got properly applied
var marks = editor._codeMirror.getAllMarks();
expect(marks.length).toBe(1);
expect(marks[0].tagID).toEqual(result.finalDOM.tagID);
});
});
it("should handle adding a <head> tag into a document", function () {
setupEditor("<html>{{0}}</html>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// Type the opening tag--should be invalid all the way
result = typeAndExpect(editor, previousDOM, offsets[0], "<head></head");
expect(result.finalInvalid).toBe(true);
// Finally become valid by closing the end tag. Note that this elementInsert
// should be treated specially by RemoteFunctions not to actually insert the
// element, but just copy its ID to the autocreated HTML element.
result = typeAndExpect(editor, result.finalDOM, result.finalPos, ">", [
function (dom) { // check for tagIDs relative to the DOM after typing
return [
{
type: "elementInsert",
tag: "head",
attributes: {},
tagID: dom.children[0].tagID,
parentID: dom.tagID,
lastChild: true
}
];
}
], true); // because we were invalid before this operation
});
});
it("should handle adding a <body> tag into a document", function () {
setupEditor("<html><head></head>{{0}}</html>", true);
runs(function () {
var previousDOM = HTMLSimpleDOM.build(editor.document.getText()),
result;
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
// Type the opening tag--should be invalid all the way
result = typeAndExpect(editor, previousDOM, offsets[0], "<body");
expect(result.finalInvalid).toBe(true);
// Finally become valid by closing the start tag. Note that this elementInsert
// should be treated specially by RemoteFunctions not to actually insert the
// element, but just copy its ID to the autocreated HTML element.
result = typeAndExpect(editor, result.finalDOM, result.finalPos, ">", [
function (dom) { // check for tagIDs relative to the DOM after typing
return [
{
type: "elementInsert",
tag: "body",
attributes: {},
tagID: dom.children[1].tagID,
parentID: dom.tagID,
lastChild: true
}
];
}
], true); // because we were invalid before this operation
});
});
it("should handle adding a space after </html>", function () {
setupEditor("<html></html>", true);
runs(function () {
doEditTest(editor.document.getText(), function (editor, previousDOM) {
editor.document.replaceRange(" ", {line: 0, ch: 13});
}, function (result, previousDOM, incremental) {
}, true);
});
});
it("should represent simple new tag insert", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<div>New Content</div>", {line: 15, ch: 0});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[5];
expect(newElement.tag).toEqual("div");
expect(newElement.tagID).not.toEqual(newElement.parent.tagID);
expect(newElement.children[0].content).toEqual("New Content");
expect(result.edits.length).toEqual(4);
var beforeID = newElement.parent.children[7].tagID,
afterID = newElement.parent.children[3].tagID;
expect(result.edits[0]).toEqual({
type: "textReplace",
parentID: newElement.parent.tagID,
afterID: afterID,
beforeID: beforeID,
content: "\n\n"
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "div",
attributes: {},
tagID: newElement.tagID,
parentID: newElement.parent.tagID,
beforeID: beforeID
});
expect(result.edits[2]).toEqual({
type: "textInsert",
parentID: newElement.parent.tagID,
afterID: newElement.tagID,
beforeID: beforeID,
content: "\n\n"
});
expect(result.edits[3]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: "New Content",
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should be able to add two tags at once", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<div>New Content</div><div>More new content</div>", {line: 15, ch: 0});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[5];
var newElement2 = newDOM.children[3].children[6];
expect(newElement.tag).toEqual("div");
expect(newElement2.tag).toEqual("div");
expect(newElement.tagID).not.toEqual(newElement.parent.tagID);
expect(newElement2.tagID).not.toEqual(newElement.tagID);
expect(newElement.children[0].content).toEqual("New Content");
expect(newElement2.children[0].content).toEqual("More new content");
expect(result.edits.length).toEqual(6);
var beforeID = newElement.parent.children[8].tagID,
afterID = newElement.parent.children[3].tagID;
expect(result.edits[0]).toEqual({
type: "textReplace",
parentID: newElement.parent.tagID,
afterID: afterID,
beforeID: beforeID,
content: "\n\n"
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "div",
attributes: {},
tagID: newElement.tagID,
parentID: newElement.parent.tagID,
beforeID: beforeID
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tag: "div",
attributes: {},
tagID: newElement2.tagID,
parentID: newElement2.parent.tagID,
beforeID: beforeID
});
expect(result.edits[3]).toEqual({
type: "textInsert",
parentID: newElement2.parent.tagID,
afterID: newElement2.tagID,
beforeID: beforeID,
content: "\n\n"
});
expect(result.edits[4]).toEqual({
type: "textInsert",
parentID: newElement2.tagID,
content: "More new content",
lastChild: true
});
expect(result.edits[5]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: "New Content",
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should be able to paste a tag with a nested tag", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<div>New <em>Awesome</em> Content</div>", {line: 13, ch: 0});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[3],
newChild = newElement.children[1];
expect(newElement.tag).toEqual("div");
expect(newElement.tagID).not.toEqual(newElement.parent.tagID);
expect(newElement.children.length).toEqual(3);
expect(newElement.children[0].content).toEqual("New ");
expect(newChild.tag).toEqual("em");
expect(newChild.tagID).not.toEqual(newElement.tagID);
expect(newChild.children.length).toEqual(1);
expect(newChild.children[0].content).toEqual("Awesome");
expect(newElement.children[2].content).toEqual(" Content");
expect(result.edits.length).toEqual(5);
var beforeID = newElement.parent.children[4].tagID;
expect(result.edits[0]).toEqual({
type: "elementInsert",
tag: "div",
attributes: {},
tagID: newElement.tagID,
parentID: newElement.parent.tagID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: "New ",
lastChild: true
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tag: "em",
attributes: {},
tagID: newChild.tagID,
parentID: newElement.tagID,
lastChild: true
});
expect(result.edits[3]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: " Content",
lastChild: true
});
expect(result.edits[4]).toEqual({
type: "textInsert",
parentID: newChild.tagID,
content: "Awesome",
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should handle inserting an element as the first child", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<div>New Content</div>", {line: 10, ch: 12});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[0],
parent = newElement.parent,
parentID = parent.tagID,
beforeID = parent.children[2].tagID;
// TODO: More optimally, this would take
// 2 edits rather than 4:
// * an elementInsert for the new element
// * a textInsert for the new text of the
// new element.
//
// It current requires 4 edits because the
// whitespace text node that comes after
// the body tag is deleted and recreated
expect(result.edits.length).toBe(4);
expect(result.edits[0]).toEqual({
type: "textDelete",
parentID: parentID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
parentID: parentID,
tag: "div",
attributes: {},
tagID: newElement.tagID,
beforeID: beforeID
});
expect(result.edits[2]).toEqual({
type: "textInsert",
parentID: parentID,
content: "\n\n",
afterID: newElement.tagID,
beforeID: beforeID
});
expect(result.edits[3]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: "New Content",
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should handle inserting an element as the last child", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
// insert a new element at the end of a paragraph
editor.document.replaceRange("<strong>New Content</strong>", {line: 33, ch: 0});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[7].children[3],
parent = newElement.parent,
parentID = parent.tagID;
expect(result.edits.length).toBe(2);
expect(result.edits[0]).toEqual({
type: "elementInsert",
parentID: parentID,
lastChild: true,
tag: "strong",
attributes: {},
tagID: newElement.tagID
});
expect(result.edits[1]).toEqual({
type: "textInsert",
parentID: newElement.tagID,
content: "New Content",
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should handle inserting an element before an existing text node", function () {
setupEditor(WellFormedDoc);
runs(function () {
editor.document.replaceRange("<strong>pre-edit child</strong>", {line: 33, ch: 0});
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<strong>New Content</strong>", {line: 29, ch: 59});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[7].children[2],
parent = newElement.parent,
parentID = parent.tagID,
afterID = parent.children[1].tagID,
beforeID = parent.children[4].tagID;
expect(result.edits.length).toBe(4);
expect(result.edits[0]).toEqual({
type: "textDelete",
parentID: parentID,
afterID: afterID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
parentID: parentID,
beforeID: beforeID,
tag: "strong",
attributes: {},
tagID: newElement.tagID
});
expect(result.edits[2]).toEqual({
type: "textInsert",
parentID: parentID,
content: jasmine.any(String),
afterID: newElement.tagID,
beforeID: beforeID
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should represent simple new tag insert immediately after previous tag before text before tag", function () {
setupEditor(WellFormedDoc);
runs(function () {
var ed;
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
ed = editor;
editor.document.replaceRange("<div>New Content</div>", {line: 12, ch: 38});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
// first child is whitespace, second child is <h1>, third child is new tag
var newElement = newDOM.children[3].children[2],
afterID = newElement.parent.children[1].tagID,
beforeID = newElement.parent.children[4].tagID;
expect(newElement.tag).toEqual("div");
expect(newElement.tagID).not.toEqual(newElement.parent.tagID);
expect(newElement.children[0].content).toEqual("New Content");
// 4 edits:
// - delete original \n
// - insert new tag
// - insert text in tag
// - re-add \n after tag
expect(result.edits.length).toEqual(4);
expect(result.edits[0]).toEqual({
type: "textDelete",
parentID: newElement.parent.tagID,
afterID: afterID,
beforeID: beforeID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "div",
attributes: {},
tagID: newElement.tagID,
parentID: newElement.parent.tagID,
beforeID: beforeID
});
expect(result.edits[2]).toEqual({
type: "textInsert",
parentID: newElement.parent.tagID,
content: jasmine.any(String),
afterID: newElement.tagID,
beforeID: beforeID
});
expect(result.edits[3]).toEqual({
type: "textInsert",
content: "New Content",
parentID: newElement.tagID,
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should handle new text insert between tags after whitespace", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("New Content", {line: 13, ch: 0});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[2];
expect(newElement.content).toEqual("\nNew Content");
expect(result.edits.length).toEqual(1);
expect(result.edits[0]).toEqual({
type: "textReplace",
content: "\nNew Content",
parentID: newElement.parent.tagID,
afterID: newDOM.children[3].children[1].tagID,
beforeID: newDOM.children[3].children[3].tagID
});
if (incremental) {
// this should have been an incremental edit since it was just text
expect(result._wasIncremental).toBe(true);
}
}
);
});
});
it("should handle inserting an element in the middle of text", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("<img>", {line: 12, ch: 19});
},
function (result, previousDOM, incremental) {
var newDOM = result.dom;
var newElement = newDOM.children[3].children[1].children[1];
expect(newElement.tag).toEqual("img");
expect(newDOM.children[3].children[1].children[0].content).toEqual("GETTING STARTED");
expect(newDOM.children[3].children[1].children[2].content).toEqual(" WITH BRACKETS");
expect(result.edits.length).toEqual(3);
expect(result.edits[0]).toEqual({
type: "textReplace",
content: "GETTING STARTED",
parentID: newElement.parent.tagID
});
expect(result.edits[1]).toEqual({
type: "elementInsert",
tag: "img",
attributes: {},
tagID: newElement.tagID,
parentID: newElement.parent.tagID,
lastChild: true
});
expect(result.edits[2]).toEqual({
type: "textInsert",
content: " WITH BRACKETS",
parentID: newElement.parent.tagID,
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should handle reordering of children in one step as a delete/insert", function () {
setupEditor("<p>{{0}}<img><br>{{1}}</p>", true);
var oldImgID, oldBrID;
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
oldImgID = previousDOM.children[0].tagID;
oldBrID = previousDOM.children[1].tagID;
editor.document.replaceRange("<br><img>", offsets[0], offsets[1]);
},
function (result, previousDOM, incremental) {
var newBrElement = result.dom.children[0],
newImgElement = result.dom.children[1];
expect(result.edits.length).toEqual(4);
expect(result.edits[0]).toEqual({
type: "elementDelete",
tagID: oldImgID
});
expect(result.edits[1]).toEqual({
type: "elementDelete",
tagID: oldBrID
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tag: "br",
attributes: {},
tagID: newBrElement.tagID,
parentID: result.dom.tagID,
lastChild: true
});
expect(result.edits[3]).toEqual({
type: "elementInsert",
tag: "img",
attributes: {},
tagID: newImgElement.tagID,
parentID: result.dom.tagID,
lastChild: true
});
if (incremental) {
// this should not have been an incremental edit since it changed the DOM structure
expect(result._wasIncremental).toBe(false);
}
}
);
});
});
it("should support deleting across tags", function () {
setupEditor(WellFormedDoc);
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
editor.document.replaceRange("", {line: 20, ch: 11}, {line: 28, ch: 3});
},
function (result, previousDOM, incremental) {
if (incremental) {
return;
}
var newDOM = result.dom;
var modifiedParagraph = newDOM.children[3].children[5];
expect(modifiedParagraph.tag).toEqual("p");
expect(modifiedParagraph.children.length).toEqual(3);
var emTag = modifiedParagraph.children[1];
expect(emTag.tag).toEqual("em");
var deletedParagraph = previousDOM.children[3].children[7];
expect(deletedParagraph.tag).toEqual("p");
var aTag = previousDOM.children[3].children[9];
expect(aTag.tag).toEqual("a");
expect(result.edits.length).toEqual(6);
expect(result.edits[0]).toEqual({
type: "rememberNodes",
tagIDs: [emTag.tagID]
});
expect(result.edits[1]).toEqual({
type: "elementDelete",
tagID: deletedParagraph.tagID
});
expect(result.edits[2]).toEqual({
type: "textReplace",
content: "\n\n\n",
parentID: modifiedParagraph.parent.tagID,
afterID: modifiedParagraph.tagID,
beforeID: aTag.tagID
});
expect(result.edits[3]).toEqual({
type: "textReplace",
content: "\n Welcome\n ",
parentID: modifiedParagraph.tagID
});
expect(result.edits[4]).toEqual({
type: "elementMove",
tagID: emTag.tagID,
parentID: modifiedParagraph.tagID,
lastChild: true
});
expect(result.edits[5]).toEqual({
type: "textInsert",
parentID: modifiedParagraph.tagID,
lastChild: true,
content: jasmine.any(String)
});
}
);
});
});
it("should support reparenting a node with new parent under the old parent", function () {
setupEditor(WellFormedDoc);
var currentText = WellFormedDoc;
var movingParagraph, newDiv;
runs(function () {
doEditTest(currentText, function (editor, previousDOM) {
editor.document.replaceRange("<div>Hello</div>", { line: 14, ch: 0 });
currentText = editor.document.getText();
}, function (result, previousDOM, incremental) {
}, false);
});
runs(function () {
doEditTest(currentText, function (editor, previousDOM) {
movingParagraph = previousDOM.children[3].children[7];
newDiv = previousDOM.children[3].children[5];
editor.document.replaceRange("", { line: 14, ch: 10 }, { line: 14, ch: 16 });
editor.document.replaceRange("</div>", { line: 24, ch: 0 });
}, function (result, previousDOM, incremental) {
expect(movingParagraph.tag).toBe("p");
expect(newDiv.tag).toBe("div");
expect(result.edits.length).toBe(5);
expect(result.edits[0].type).toBe("rememberNodes");
expect(result.edits[0].tagIDs).toEqual([movingParagraph.tagID]);
// The text replace should not refer to the moving node, because it is
// going to be removed from the DOM.
expect(result.edits[1].type).toEqual("textReplace");
expect(result.edits[1].afterID).not.toEqual(movingParagraph.tagID);
expect(result.edits[1].beforeID).not.toEqual(movingParagraph.tagID);
expect(result.edits[3].type).toBe("elementMove");
expect(result.edits[3].tagID).toBe(movingParagraph.tagID);
expect(result.edits[3].parentID).toBe(newDiv.tagID);
}, false);
});
});
it("should support undo of a tag merge", function () {
setupEditor(WellFormedDoc);
var currentText = WellFormedDoc;
runs(function () {
doEditTest(currentText, function (editor, previousDOM) {
editor.document.replaceRange("", { line: 23, ch: 0 }, { line: 29, ch: 0 });
currentText = editor.document.getText();
}, function (result, previousDOM, incremental) {
}, false);
});
runs(function () {
doEditTest(currentText, function (editor, previousDOM) {
editor.undo();
}, function (result, previousDOM, incremental) {
var emNode = previousDOM.children[3].children[5].children[1];
expect(emNode.tag).toBe("em");
expect(result.edits.length).toBe(7);
var edit = result.edits[0];
expect(edit.type).toBe("rememberNodes");
expect(edit.tagIDs).toEqual([emNode.tagID]);
edit = result.edits[1];
expect(edit.type).toBe("elementInsert");
expect(edit.tag).toBe("p");
var newParaID = edit.tagID;
edit = result.edits[5];
expect(edit.type).toBe("elementMove");
expect(edit.tagID).toBe(emNode.tagID);
expect(edit.parentID).toBe(newParaID);
}, false, true);
});
});
it("should handle tag changes", function () {
setupEditor(WellFormedDoc);
var heading,
h1,
para;
runs(function () {
doEditTest(
WellFormedDoc,
function (editor, previousDOM) {
heading = previousDOM.children[3].children[3];
h1 = previousDOM.children[3].children[1];
para = previousDOM.children[3].children[5];
editor.document.replaceRange("h3", { line: 13, ch: 1 }, { line: 13, ch: 3 });
editor.document.replaceRange("h3", { line: 13, ch: 25 }, { line: 13, ch: 27 });
},
function (result, previousDOM, incremental) {
expect(heading.tag).toBe("h2");
expect(para.tag).toBe("p");
var newHeading = result.dom.children[3].children[3];
expect(newHeading.tag).toBe("h3");
expect(result.edits.length).toBe(5);
expect(result.edits[0]).toEqual({
type: "elementDelete",
tagID: heading.tagID
});
expect(result.edits[1]).toEqual({
type: "textReplace",
parentID: newHeading.parent.tagID,
beforeID: para.tagID,
afterID: h1.tagID,
content: "\n"
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tagID: newHeading.tagID,
parentID: newHeading.parent.tagID,
attributes: {},
tag: "h3",
beforeID: para.tagID
});
expect(result.edits[3]).toEqual({
type: "textInsert",
content: "\n\n\n\n",
parentID: newHeading.parent.tagID,
beforeID: para.tagID,
afterID: newHeading.tagID
});
expect(result.edits[4]).toEqual({
type: "textInsert",
content: "This is your guide!",
parentID: newHeading.tagID,
lastChild: true
});
},
false
);
});
});
it("should handle void element tag changes", function () {
setupEditor(WellFormedDoc);
runs(function () {
doEditTest(
WellFormedDoc,
function (editor, previousDOM) {
editor.document.replaceRange("br", { line: 37, ch: 5 }, { line: 37, ch: 8 });
},
function (result, previousDOM, incremental) {
var br = result.dom.children[3].children[9].children[1],
img = previousDOM.children[3].children[9].children[1];
expect(br.tag).toBe("br");
expect(img.tag).toBe("img");
expect(result.edits.length).toBe(4);
expect(result.edits[0]).toEqual({
type: "elementDelete",
tagID: img.tagID
});
expect(result.edits[1]).toEqual({
type: "textReplace",
content: "\n ",
parentID: br.parent.tagID
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tagID: br.tagID,
parentID: br.parent.tagID,
attributes: {
"alt": "A screenshot showing CSS Quick Edit",
"src": "screenshots/brackets-quick-edit.png"
},
tag: "br",
lastChild: true
});
expect(result.edits[3]).toEqual({
type: "textInsert",
content: "\n",
parentID: br.parent.tagID,
lastChild: true
});
},
false
);
});
});
it("should handle tag changes with child elements", function () {
setupEditor(WellFormedDoc);
var para,
earlierPara;
runs(function () {
doEditTest(
WellFormedDoc,
function (editor, previousDOM) {
para = previousDOM.children[3].children[7];
earlierPara = previousDOM.children[3].children[5];
editor.document.replaceRange("div", { line: 28, ch: 1 }, { line: 28, ch: 2 });
editor.document.replaceRange("div", { line: 33, ch: 2 }, { line: 33, ch: 3 });
},
function (result, previousDOM, incremental) {
var div = result.dom.children[3].children[7],
em = div.children[1],
a = result.dom.children[3].children[9];
expect(para.tag).toBe("p");
expect(div.tag).toBe("div");
expect(em.tag).toBe("em");
expect(result.edits.length).toBe(8);
expect(result.edits[0]).toEqual({
type: "rememberNodes",
tagIDs: [em.tagID]
});
expect(result.edits[1]).toEqual({
type: "elementDelete",
tagID: para.tagID
});
expect(result.edits[2]).toEqual({
type: "textReplace",
content: "\n\n\n",
parentID: div.parent.tagID,
afterID: earlierPara.tagID,
beforeID: a.tagID
});
expect(result.edits[3]).toEqual({
type: "elementInsert",
tag: "div",
tagID: div.tagID,
parentID: div.parent.tagID,
attributes: {},
beforeID: a.tagID
});
expect(result.edits[4]).toEqual({
type: "textInsert",
content: "\n\n\n",
parentID: div.parent.tagID,
afterID: div.tagID,
beforeID: a.tagID
});
expect(result.edits[5]).toEqual({
type: "textInsert",
content: "\n ",
parentID: div.tagID,
lastChild: true
});
expect(result.edits[6]).toEqual({
type: "elementMove",
tagID: em.tagID,
parentID: div.tagID,
lastChild: true
});
expect(result.edits[7]).toEqual({
type: "textInsert",
parentID: div.tagID,
content: jasmine.any(String),
lastChild: true
});
},
false
);
});
});
it("should handle multiple inserted tags and text", function () {
setupEditor("<h1><strong>Emphasized</strong> Hello </h1>");
var h1,
strong;
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
h1 = previousDOM;
strong = previousDOM.children[0];
editor.document.replaceRange("<em>Foo</em> bar <strong>Baz!</strong>", {line: 0, ch: 4}, {line: 0, ch: 31});
},
function (result, previousDOM, incremental) {
var em = result.dom.children[0],
strong2 = result.dom.children[2];
expect(result.edits.length).toBe(8);
expect(result.edits[0]).toEqual({
type: "elementDelete",
tagID: strong.tagID
});
expect(result.edits[1]).toEqual({
type: "textDelete",
parentID: h1.tagID
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tag: "em",
tagID: em.tagID,
parentID: h1.tagID,
attributes: {},
lastChild: true
});
expect(result.edits[3]).toEqual({
type: "textInsert",
parentID: h1.tagID,
lastChild: true,
content: " bar "
});
expect(result.edits[4]).toEqual({
type: "elementInsert",
tag: "strong",
tagID: strong2.tagID,
parentID: h1.tagID,
lastChild: true,
attributes: {}
});
expect(result.edits[5]).toEqual({
type: "textInsert",
parentID: h1.tagID,
lastChild: true,
content: " Hello "
});
expect(result.edits[6]).toEqual({
type: "textInsert",
parentID: strong2.tagID,
content: "Baz!",
lastChild: true
});
expect(result.edits[7]).toEqual({
type: "textInsert",
parentID: em.tagID,
content: "Foo",
lastChild: true
});
}
);
});
});
it("should handle pasting a tag over multiple tags and text", function () {
setupEditor("<h1>before<strong>Strong</strong>Hello<em>Emphasized</em>after</h1>");
var h1,
strong,
em;
runs(function () {
doFullAndIncrementalEditTest(
function (editor, previousDOM) {
h1 = previousDOM;
strong = previousDOM.children[1];
em = previousDOM.children[3];
editor.document.replaceRange("<i>Italic</i>", {line: 0, ch: 10}, {line: 0, ch: 57});
},
function (result, previousDOM, incremental) {
var i = result.dom.children[1];
expect(result.edits.length).toBe(5);
expect(result.edits[0]).toEqual({
type: "elementDelete",
tagID: strong.tagID
});
expect(result.edits[1]).toEqual({
type: "textReplace",
parentID: h1.tagID,
beforeID: em.tagID,
content: "before"
});
expect(result.edits[2]).toEqual({
type: "elementInsert",
tag: "i",
tagID: i.tagID,
parentID: h1.tagID,
attributes: {},
beforeID: em.tagID
});
expect(result.edits[3]).toEqual({
type: "elementDelete",
tagID: em.tagID
});
expect(result.edits[4]).toEqual({
type: "textInsert",
parentID: i.tagID,
content: "Italic",
lastChild: true
});
}
);
});
});
});
});
});