| Index: third_party/WebKit/LayoutTests/editing/assert_selection.js
|
| diff --git a/third_party/WebKit/LayoutTests/editing/assert_selection.js b/third_party/WebKit/LayoutTests/editing/assert_selection.js
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..9599a1745076f392b7e4c0513afbe767a9f95734
|
| --- /dev/null
|
| +++ b/third_party/WebKit/LayoutTests/editing/assert_selection.js
|
| @@ -0,0 +1,612 @@
|
| +// Copyright 2016 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
| +
|
| +'use strict';
|
| +
|
| +// This file provides |assert_selection(sample, tester, expectedText)| assertion
|
| +// to W3C test harness to write editing test cases easier.
|
| +//
|
| +// |sample| is an HTML fragment text which is inserted as |innerHTML|. It should
|
| +// have at least one focus boundary point marker "|" and at most one anchor
|
| +// boundary point marker "^".
|
| +//
|
| +// |tester| is either name with parameter of execCommand or function taking
|
| +// one parameter |Selection|.
|
| +//
|
| +// |expectedText| is an HTML fragment text containing at most one focus marker
|
| +// and anchor marker. If resulting selection is none, you don't need to have
|
| +// anchor and focus markers.
|
| +//
|
| +// Example:
|
| +// test(() => {
|
| +// assert_selection(
|
| +// '|foo',
|
| +// (selection) => selection.modify('extent', 'forward, 'character'),
|
| +// '<a href="http://bar">^f|oo</a>'
|
| +// });
|
| +//
|
| +// test(() => {
|
| +// assert_selection(
|
| +// 'x^y|z',
|
| +// 'bold', // execCommand name as a test
|
| +// 'x<b>y</b>z',
|
| +// 'Insert B tag');
|
| +// });
|
| +//
|
| +// test(() => {
|
| +// assert_selection(
|
| +// 'x^y|z',
|
| +// 'createLink http://foo', // execCommand name and parameter
|
| +// 'x<a href="http://foo/">y</a></b>z',
|
| +// 'Insert B tag');
|
| +// });
|
| +//
|
| +//
|
| +
|
| +// TODO(yosin): Please use "clang-format -style=Chromium -i" for formatting
|
| +// this file.
|
| +
|
| +(function() {
|
| +/**
|
| + * @param {!Node} node
|
| + * @return {boolean}
|
| + */
|
| +function isCharacterData(node) {
|
| + return node.nodeType === Node.TEXT_NODE ||
|
| + node.nodeType === Node.COMMENT_NODE;
|
| +}
|
| +
|
| +/**
|
| + * @param {!Node} node
|
| + * @return {boolean}
|
| + */
|
| +function isElement(node) {
|
| + return node.nodeType === Node.ELEMENT_NODE;
|
| +}
|
| +
|
| +/**
|
| + * @param {!Node} node
|
| + * @param {number} offset
|
| + */
|
| +function checkValidNodeAndOffset(node, offset) {
|
| + if (!node)
|
| + throw new Error('Node parameter should not be a null.');
|
| + if (offset < 0)
|
| + throw new Error(`Assumes ${offset} >= 0`);
|
| + if (isElement(node)) {
|
| + if (offset > node.childNodes.length)
|
| + throw new Error(`Bad offset ${offset} for ${node}`);
|
| + return;
|
| + }
|
| + if (isCharacterData(node)) {
|
| + if (offset > node.nodeValue.length)
|
| + throw new Error(`Bad offset ${offset} for ${node}`);
|
| + return;
|
| + }
|
| + throw new Error(`Invalid node: ${node}`);
|
| +}
|
| +
|
| +class SampleSelection {
|
| + /** @public */
|
| + constructor() {
|
| + /** @type {?Node} */
|
| + this.anchorNode_ = null;
|
| + /** @type {number} */
|
| + this.anchorOffset_ = 0;
|
| + /** @type {?Node} */
|
| + this.focusNode_ = null;
|
| + /** @type {number} */
|
| + this.focusOffset_ = 0;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @param {!Node} node
|
| + * @param {number} offset
|
| + */
|
| + collapse(node, offset) {
|
| + checkValidNodeAndOffset(node, offset);
|
| + this.anchorNode_ = this.focusNode_ = node;
|
| + this.anchorOffset_ = this.focusOffset_ = offset;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @param {!Node} node
|
| + * @param {number} offset
|
| + */
|
| + extend(node, offset) {
|
| + checkValidNodeAndOffset(node, offset);
|
| + this.focusNode_ = node;
|
| + this.focusOffset_ = offset;
|
| + }
|
| +
|
| + /** @public @return {?Node} */
|
| + get anchorNode() {
|
| + console.assert(!this.isNone, 'Selection should not be a none.');
|
| + return this.anchorNode_;
|
| + }
|
| + /** @public @return {number} */
|
| + get anchorOffset() {
|
| + console.assert(!this.isNone, 'Selection should not be a none.');
|
| + return this.anchorOffset_;
|
| + }
|
| + /** @public @return {?Node} */
|
| + get focusNode() {
|
| + console.assert(!this.isNone, 'Selection should not be a none.');
|
| + return this.focusNode_;
|
| + }
|
| + /** @public @return {number} */
|
| + get focusOffset() {
|
| + console.assert(!this.isNone, 'Selection should not be a none.');
|
| + return this.focusOffset_;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @return {boolean}
|
| + */
|
| + get isCollapsed() {
|
| + return this.anchorNode === this.focusNode &&
|
| + this.anchorOffset === this.focusOffset;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @return {boolean}
|
| + */
|
| + get isNone() { return this.anchorNode_ === null; }
|
| +
|
| + /**
|
| + * @public
|
| + * @param {!Selection} domSelection
|
| + * @return {!SampleSelection}
|
| + */
|
| + static fromDOMSelection(domSelection) {
|
| + /** type {!SampleSelection} */
|
| + const selection = new SampleSelection();
|
| + selection.anchorNode_ = domSelection.anchorNode;
|
| + selection.anchorOffset_ = domSelection.anchorOffset;
|
| + selection.focusNode_ = domSelection.focusNode;
|
| + selection.focusOffset_ = domSelection.focusOffset;
|
| + return selection;
|
| + }
|
| +
|
| + /** @override */
|
| + toString() {
|
| + if (this.isNone)
|
| + return 'SampleSelection()';
|
| + if (this.isCollapsed)
|
| + return `SampleSelection(${this.focusNode_}@${this.focusOffset_})`;
|
| + return `SampleSelection(anchor: ${this.anchorNode_}@${this.anchorOffset_}` +
|
| + `focus: ${this.focusNode_}@${this.focusOffset_}`;
|
| + }
|
| +}
|
| +
|
| +// Extracts selection from marker "^" as anchor and "|" as focus from
|
| +// DOM tree and removes them.
|
| +class Parser {
|
| + /** @private */
|
| + constructor() {
|
| + /** @type {?Node} */
|
| + this.anchorNode_ = null;
|
| + /** @type {number} */
|
| + this.anchorOffset_ = 0;
|
| + /** @type {?Node} */
|
| + this.focusNode_ = null;
|
| + /** @type {number} */
|
| + this.focusOffset_ = 0;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @return {!SampleSelection}
|
| + */
|
| + get selection() {
|
| + const selection = new SampleSelection();
|
| + if (!this.anchorNode_ && !this.focusNode_)
|
| + return selection;
|
| + if (this.anchorNode_ && this.focusNode_) {
|
| + selection.collapse(this.anchorNode_, this.anchorOffset_);
|
| + selection.extend(this.focusNode_, this.focusOffset_);
|
| + return selection;
|
| + }
|
| + if (this.focusNode_) {
|
| + selection.collapse(this.focusNode_, this.focusOffset_);
|
| + return selection;
|
| + }
|
| + throw new Error('There is no focus marker');
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!CharacterData} node
|
| + * @param {number} nodeIndex
|
| + */
|
| + handleCharacterData(node, nodeIndex) {
|
| + /** @type {string} */
|
| + const text = node.nodeValue;
|
| + /** @type {number} */
|
| + const anchorOffset = text.indexOf('^');
|
| + /** @type {number} */
|
| + const focusOffset = text.indexOf('|');
|
| + /** @type {!Node} */
|
| + const parentNode = node.parentNode;
|
| + node.nodeValue = text.replace('^', '').replace('|', '');
|
| + if (node.nodeValue.length == 0) {
|
| + if (anchorOffset >= 0)
|
| + this.rememberSelectionAnchor(parentNode, nodeIndex);
|
| + if (focusOffset >= 0)
|
| + this.rememberSelectionFocus(parentNode, nodeIndex);
|
| + node.remove();
|
| + return;
|
| + }
|
| + if (anchorOffset >= 0 && focusOffset >= 0) {
|
| + if (anchorOffset > focusOffset) {
|
| + this.rememberSelectionAnchor(node, anchorOffset - 1);
|
| + this.rememberSelectionFocus(node, focusOffset);
|
| + return;
|
| + }
|
| + this.rememberSelectionAnchor(node, anchorOffset);
|
| + this.rememberSelectionFocus(node, focusOffset - 1);
|
| + return;
|
| + }
|
| + if (anchorOffset >= 0) {
|
| + this.rememberSelectionAnchor(node, anchorOffset);
|
| + return;
|
| + }
|
| + if (focusOffset < 0)
|
| + return;
|
| + this.rememberSelectionFocus(node, focusOffset);
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Element} element
|
| + */
|
| + handleElementNode(element) {
|
| + /** @type {number} */
|
| + let childIndex = 0;
|
| + for (const child of Array.from(element.childNodes)) {
|
| + this.parseInternal(child, childIndex);
|
| + if (!child.parentNode)
|
| + continue;
|
| + ++childIndex;
|
| + }
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Node} node
|
| + * @return {!SampleSelection}
|
| + */
|
| + parse(node) {
|
| + this.parseInternal(node, 0);
|
| + return this.selection;
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Node} node
|
| + * @param {number} nodeIndex
|
| + */
|
| + parseInternal(node, nodeIndex) {
|
| + if (isElement(node))
|
| + return this.handleElementNode(node);
|
| + if (isCharacterData(node))
|
| + return this.handleCharacterData(node, nodeIndex);
|
| + throw new Error(`Unexpected node ${node}`);
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Node} node
|
| + * @param {number} offset
|
| + */
|
| + rememberSelectionAnchor(node, offset) {
|
| + checkValidNodeAndOffset(node, offset);
|
| + console.assert(
|
| + this.anchorNode_ === null, 'Anchor marker should be one.',
|
| + this.anchorNode_, this.anchorOffset_);
|
| + this.anchorNode_ = node;
|
| + this.anchorOffset_ = offset;
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Node} node
|
| + * @param {number} offset
|
| + */
|
| + rememberSelectionFocus(node, offset) {
|
| + checkValidNodeAndOffset(node, offset);
|
| + console.assert(
|
| + this.focusNode_ === null, 'Focus marker should be one.',
|
| + this.focusNode_, this.focusOffset_);
|
| + this.focusNode_ = node;
|
| + this.focusOffset_ = offset;
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @param {!Node} node
|
| + * @return {!SampleSelection}
|
| + */
|
| + static parse(node) { return (new Parser()).parse(node); }
|
| +}
|
| +
|
| +// TODO(yosin): Once we can import JavaScript file from scripts, we should
|
| +// import "imported/wpt/html/resources/common.js", since |HTML5_VOID_ELEMENTS|
|
| +// is defined in there.
|
| +/**
|
| + * @const @type {!Set<string>}
|
| + * only void (without end tag) HTML5 elements
|
| + */
|
| +const HTML5_VOID_ELEMENTS = new Set([
|
| + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input',
|
| + 'keygen', 'link', 'meta', 'param', 'source','track', 'wbr' ]);
|
| +
|
| +class Serializer {
|
| + /**
|
| + * @public
|
| + * @param {!SampleSelection} selection
|
| + */
|
| + constructor(selection) {
|
| + /** @type {!SampleSelection} */
|
| + this.selection_ = selection;
|
| + /** @type {!Array<strings>} */
|
| + this.strings_ = [];
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {string} string
|
| + */
|
| + emit(string) { this.strings_.push(string); }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!CharacterData} node
|
| + */
|
| + handleCharacterData(node) {
|
| + /** @type {string} */
|
| + const text = node.nodeValue;
|
| + /** @type {number} */
|
| + const anchorOffset = this.selection_.anchorOffset;
|
| + /** @type {number} */
|
| + const focusOffset = this.selection_.focusOffset;
|
| + if (node === this.selection_.focusNode &&
|
| + node === this.selection_.anchorNode) {
|
| + if (anchorOffset === focusOffset) {
|
| + this.emit(text.substr(0, focusOffset));
|
| + this.emit('|');
|
| + this.emit(text.substr(focusOffset));
|
| + return;
|
| + }
|
| + if (anchorOffset < focusOffset) {
|
| + this.emit(text.substr(0, anchorOffset));
|
| + this.emit('^');
|
| + this.emit(text.substr(anchorOffset, focusOffset - anchorOffset));
|
| + this.emit('|');
|
| + this.emit(text.substr(focusOffset));
|
| + return;
|
| + }
|
| + this.emit(text.substr(0, focusOffset));
|
| + this.emit('|');
|
| + this.emit(text.substr(focusOffset, anchorOffset - focusOffset));
|
| + this.emit('^');
|
| + this.emit(text.substr(anchorOffset));
|
| + return;
|
| + }
|
| + if (node === this.selection_.anchorNode) {
|
| + this.emit(text.substr(0, anchorOffset));
|
| + this.emit('^');
|
| + this.emit(text.substr(anchorOffset));
|
| + return;
|
| + }
|
| + if (node === this.selection_.focusNode) {
|
| + this.emit(text.substr(0, focusOffset));
|
| + this.emit('|');
|
| + this.emit(text.substr(focusOffset));
|
| + return;
|
| + }
|
| + this.emit(text);
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!HTMLElement} element
|
| + * @param {number} nodeIndex
|
| + */
|
| + handleElementNode(element, nodeIndex) {
|
| + if (element.parenNode === this.selection_.focusNode &&
|
| + nodeIndex === this.selection_.focusOffset) {
|
| + this.emit('|');
|
| + } else if (
|
| + element === this.selection_.anchorNode &&
|
| + nodeIndex === this.selection_.anchorOffset) {
|
| + this.emit('^');
|
| + }
|
| + /** @type {string} */
|
| + const tagName = element.tagName.toLowerCase();
|
| + this.emit(`<${tagName}`);
|
| + Array.from(element.attributes)
|
| + .sort((attr1, attr2) => attr1.name.localeCompare(attr2.name))
|
| + .forEach(attr => {
|
| + if (attr.value === '')
|
| + return this.emit(` ${attr.name}`);
|
| + const value = attr.value.replace(/&/g, '&')
|
| + .replace(/\u0022/g, '"')
|
| + .replace(/\u0027/g, ''');
|
| + this.emit(` ${attr.name}="${value}"`);
|
| + });
|
| + this.emit('>');
|
| + if (element.childNodes.length === 0 &&
|
| + HTML5_VOID_ELEMENTS.has(tagName)) {
|
| + return;
|
| + }
|
| + /** @type {number} */
|
| + let childIndex = 0;
|
| + for (const child of Array.from(element.childNodes)) {
|
| + this.serializeInternal(child, childIndex);
|
| + ++childIndex;
|
| + }
|
| + this.emit(`</${tagName}>`);
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + * @param {!HTMLElement} element
|
| + */
|
| + serialize(element) {
|
| + if (this.selection_.isNone)
|
| + return node.outerHTML;
|
| + this.serializeInternal(element, 0);
|
| + return this.strings_.join('');
|
| + }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {!Node} node
|
| + * @param {number} nodeIndex
|
| + */
|
| + serializeInternal(node, nodeIndex) {
|
| + if (isElement(node))
|
| + return this.handleElementNode(node, nodeIndex);
|
| + if (isCharacterData(node))
|
| + return this.handleCharacterData(node);
|
| + throw new Error(`Unexpected node ${node}`);
|
| + }
|
| +}
|
| +
|
| +class Sample {
|
| + /**
|
| + * @public
|
| + * @param {string} sampleText
|
| + */
|
| + constructor(sampleText) {
|
| + /** @const @type {!HTMLIFame} */
|
| + this.iframe_ = document.createElement('iframe');
|
| + document.body.appendChild(this.iframe_);
|
| + /** @const @type {!HTMLDocument} */
|
| + this.document_ = this.iframe_.contentDocument;
|
| + /** @const @type {!Selection} */
|
| + this.selection_ = this.iframe_.contentWindow.getSelection();
|
| + this.selection_.document = this.document_;
|
| +
|
| + this.load(sampleText);
|
| + }
|
| +
|
| + /** @return {!HTMLDocument} */
|
| + get document() { return this.document_; }
|
| +
|
| + /** @return {!Selection} */
|
| + get selection() { return this.selection_; }
|
| +
|
| + /**
|
| + * @private
|
| + * @param {string} sampleText
|
| + */
|
| + load(sampleText) {
|
| + const anchorMarker = sampleText.indexOf('^');
|
| + const focusMarker = sampleText.indexOf('|');
|
| + if (focusMarker < 0) {
|
| + throw new Error(`You should specify caret position in "${sampleText}".`);
|
| + }
|
| + if (focusMarker != sampleText.lastIndexOf('|')) {
|
| + throw new Error(
|
| + `You should have at least one focus marker "|" in "${sampleText}".`);
|
| + }
|
| + if (anchorMarker != sampleText.lastIndexOf('^')) {
|
| + throw new Error(
|
| + `You should have at most one anchor marker "^" in "${sampleText}".`);
|
| + }
|
| + this.document_.body.innerHTML = sampleText;
|
| + /** @type {!SampleSelection} */
|
| + const selection = Parser.parse(this.document_.body);
|
| + if (selection.isNone)
|
| + return;
|
| + this.selection_.collapse(selection.anchorNode, selection.anchorOffset);
|
| + this.selection_.extend(selection.focusNode, selection.focusOffset);
|
| + }
|
| +
|
| + /**
|
| + * @public
|
| + */
|
| + remove() { this.iframe_.remove(); }
|
| +
|
| + /**
|
| + * @public
|
| + * @return {string}
|
| + */
|
| + serialize() {
|
| + /** @type {!SampleSelection} */
|
| + const selection = SampleSelection.fromDOMSelection(this.selection_);
|
| + /** @type {!Serializer} */
|
| + const serializer = new Serializer(selection);
|
| + return serializer.serialize(this.document_.body.firstChild);
|
| + }
|
| +}
|
| +
|
| +function assembleDescription() {
|
| + const RE_TEST_FUNCTION =
|
| + new RegExp('at Object.test \\(.*?([^/]+?):(\\d+):(\\d+)\\)');
|
| + function getStack() {
|
| + let stack;
|
| + try {
|
| + throw new Error('get line number');
|
| + } catch (error) {
|
| + stack = error.stack.split('\n').slice(1);
|
| + }
|
| + return stack
|
| + }
|
| + for (const line of getStack()) {
|
| + const match = RE_TEST_FUNCTION.exec(line);
|
| + if (!match)
|
| + continue;
|
| + return `${match[1]}(${match[2]})`;
|
| + }
|
| + return '';
|
| +}
|
| +
|
| +/**
|
| + * @param {string} inputText
|
| + * @param {function(!Selection)|string}
|
| + * @param {string} expectedText
|
| + * @param {string=} opt_description
|
| + */
|
| +function assertSelection(
|
| + inputText, tester, expectedText, opt_description = '') {
|
| + /** @type {string} */
|
| + const description =
|
| + opt_description === '' ? assembleDescription() : opt_description;
|
| + if (expectedText.indexOf('^') != expectedText.lastIndexOf('^')) {
|
| + throw new Error(
|
| + `You should have at most one anchor marker "^" in "${expectedText}".`);
|
| + }
|
| + if (expectedText.indexOf('|') != expectedText.lastIndexOf('|')) {
|
| + throw new Error(
|
| + `You should have at most one focus marker "|" in "${expectedText}".`);
|
| + }
|
| + const sample = new Sample(inputText);
|
| + if (typeof(tester) === 'function') {
|
| + tester.call(window, sample.selection);
|
| + } else if (typeof(tester) === 'string') {
|
| + const strings = tester.split(' ');
|
| + sample.document.execCommand(strings[0], false, strings[1]);
|
| + } else {
|
| + throw new Error(`Invalid tester: ${tester}`);
|
| + }
|
| + /** @type {string} */
|
| + const actualText = sample.serialize();
|
| + // We keep sample HTML when assertion is false for ease of debugging test
|
| + // case.
|
| + if (actualText == expectedText)
|
| + sample.remove();
|
| + assert_equals(actualText, expectedText, description);
|
| +}
|
| +
|
| +// Export symbols
|
| +window.Sample = Sample;
|
| +window.assert_selection = assertSelection;
|
| +})();
|
|
|