blob: e5d5410a27cdbef252808e988a5b0e1f5a83c842 [file] [log] [blame]
// 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
// |spellcheck_test(sample, tester, expectedText, opt_title)| asynchronous test
// to W3C test harness for easier writing of spellchecker test cases.
//
// |sample| is
// - Either an HTML fragment text which is inserted as |innerHTML|, in which
// case It should have at least one focus boundary point marker "|" and at
// most one anchor boundary point marker "^" indicating the initial selection.
// TODO(editing-dev): Make initial selection work with TEXTAREA and INPUT.
// - Or a |Sample| object created by some previous test.
//
// |tester| is either name with parameter of execCommand or function taking
// one parameter |Document|.
//
// |expectedText| is an HTML fragment indicating the expected result, where text
// with spelling marker is surrounded by '#', and text with grammar marker is
// surrounded by '~'.
//
// |opt_args| is an optional object with the following optional fields:
// - title: the title of the test case.
// - callback: a callback function to be run after the test passes, which takes
// one parameter -- the |Sample| at the end of the test
// It is allowed to pass a string as |arg_args| to indicate the title only.
//
// See spellcheck_test.html for sample usage.
(function() {
const Sample = window.Sample;
// TODO(editing-dev): Once we can import JavaScript file from scripts, we should
// import "external/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' ]);
// TODO(editing-dev): Reduce code duplication with assert_selection's Serializer
// once we can import and export Javascript modules.
/**
* @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
* @return {boolean}
*/
function isHTMLInputElement(node) {
return node.nodeName === 'INPUT';
}
/**
* @param {!Node} node
* @return {boolean}
*/
function isHTMLTextAreaElement(node) {
return node.nodeName === 'TEXTAREA';
}
/**
* @param {?Range} range
* @param {!Node} node
* @param {number} offset
*/
function isAtRangeEnd(range, node, offset) {
return range && node === range.endContainer && offset === range.endOffset;
}
class MarkerSerializer {
/**
* @public
* @param {!Object} markerTypes
*/
constructor(markerTypes) {
/** @type {!Array<string>} */
this.strings_ = [];
/** @type {!Object} */
this.markerTypes_ = markerTypes;
/** @type {!Object} */
this.activeMarkerRanges_ = {};
for (let type in markerTypes)
this.activeMarkerRanges_[type] = null;
}
/**
* @private
* @param {string} string
*/
emit(string) { this.strings_.push(string); }
/**
* @private
* @param {!CharacterData} node
* @param {number} offset
* @return {number} The next offset at which a current active marker ends or
* a new marker starts. Returns node.data.length if there is
* no more markers.
*/
advancedTo(node, offset) {
var nextCheckPoint = node.data.length;
for (let type in this.markerTypes_) {
// Handle the ending of the current active marker.
if (isAtRangeEnd(this.activeMarkerRanges_[type], node, offset))
this.emit(this.markerTypes_[type]);
// Recompute the current active marker and the next check point
this.activeMarkerRanges_[type] = null;
/** @type {number} */
const markerCount = internals.markerCountForNode(node, type);
for (let i = 0; i < markerCount; ++i) {
const marker = internals.markerRangeForNode(node, type, i);
assert_equals(
marker.startContainer, node,
'Internal error: marker range not starting in the annotated node.');
assert_equals(
marker.endContainer, node,
'Internal error: marker range not ending in the annotated node.');
assert_greater_than(marker.endOffset, marker.startOffset,
'Internal error: marker range is collapsed.');
if (marker.startOffset <= offset && offset < marker.endOffset) {
this.activeMarkerRanges_[type] = marker;
nextCheckPoint = Math.min(nextCheckPoint, marker.endOffset);
// Handle the starting of the current active marker.
if (offset === marker.startOffset)
this.emit(this.markerTypes_[type]);
} else if (marker.startOffset > offset) {
nextCheckPoint = Math.min(nextCheckPoint, marker.startOffset);
}
}
}
return nextCheckPoint;
}
/**
* @private
* @param {!CharacterData} node
*/
handleCharacterData(node) {
/** @type {string} */
const text = node.nodeValue;
/** @type {number} */
const length = text.length;
for (let offset = 0; offset < length;) {
const nextCheckPoint = this.advancedTo(node, offset);
this.emit(text.substring(offset, nextCheckPoint));
offset = nextCheckPoint;
}
this.advancedTo(node, length);
}
/**
* @private
* @param {!HTMLElement} element
*/
handleInnerEditorOf(element) {
/** @type {!HTMLDivElement} */
const innerEditor = internals.innerEditorElement(element);
assert_equals(innerEditor.tagName, 'DIV',
'Internal error: inner editor DIV not found.');
innerEditor.childNodes.forEach(child => {
assert_true(isCharacterData(child),
'Internal error: inner editor having child node that is ' +
'not CharacterData.');
this.handleCharacterData(child);
});
}
/**
* @private
* @param {!HTMLInputElement} element
*/
handleInputNode(element) {
this.emit(' value="');
this.handleInnerEditorOf(element);
this.emit('"');
}
/**
* @private
* @param {!HTMLElement} element
*/
handleElementNode(element) {
/** @type {string} */
const tagName = element.tagName.toLowerCase();
this.emit(`<${tagName}`);
Array.from(element.attributes)
.sort((attr1, attr2) => attr1.name.localeCompare(attr2.name))
.forEach(attr => {
// HTMLInputElement's value attribute need special handling.
if (isHTMLInputElement(element)) {
if (attr.name === 'value')
return;
}
if (attr.value === '')
return this.emit(` ${attr.name}`);
const value = attr.value.replace(/&/g, '&amp;')
.replace(/\u0022/g, '&quot;')
.replace(/\u0027/g, '&apos;');
this.emit(` ${attr.name}="${value}"`);
});
if (isHTMLInputElement(element) && element.value)
this.handleInputNode(element);
this.emit('>');
if (HTML5_VOID_ELEMENTS.has(tagName))
return;
this.serializeChildren(element);
this.emit(`</${tagName}>`);
}
/**
* @public
* @param {!HTMLDocument} document
*/
serialize(document) {
if (document.body)
this.serializeChildren(document.body);
else
this.serializeInternal(document.documentElement);
return this.strings_.join('');
}
/**
* @private
* @param {!HTMLElement} element
*/
serializeChildren(element) {
// For TEXTAREA, handle its inner editor instead of its children.
if (isHTMLTextAreaElement(element) && element.value) {
this.handleInnerEditorOf(element);
return;
}
element.childNodes.forEach(child => this.serializeInternal(child));
}
/**
* @private
* @param {!Node} node
*/
serializeInternal(node) {
if (isElement(node))
return this.handleElementNode(node);
if (isCharacterData(node))
return this.handleCharacterData(node);
throw new Error(`Unexpected node ${node}`);
}
}
/**
* @param {!Document} document
* @return {boolean}
*/
function hasPendingSpellCheckRequest(document) {
return internals.lastSpellCheckRequestSequence(document) !==
internals.lastSpellCheckProcessedSequence(document);
}
/** @type {string} */
const kTitle = 'title';
/** @type {string} */
const kCallback = 'callback';
/** @type {string} */
const kIsSpellcheckTest = 'isSpellcheckTest';
/** @type {string} */
const kNeedsFullCheck = 'needsFullCheck';
// Spellchecker gets triggered not only by text and selection change, but also
// by focus change. For example, misspelling markers in <INPUT> disappear when
// the window loses focus, even though the selection does not change.
// Therefore, we disallow spellcheck tests from running simultaneously to
// prevent interference among them. If we call spellcheck_test while another
// test is running, the new test will be added into testQueue waiting for the
// completion of the previous test.
/** @type {boolean} */
var spellcheckTestRunning = false;
/** @type {!Array<!Object>} */
const testQueue = [];
/** @type {?Function} */
let verificationForCurrentTest = null;
/**
* @param {!Test} testObject
* @param {!Sample|string} input
* @param {function(!Document)|string} tester
* @param {string} expectedText
*/
function invokeSpellcheckTest(testObject, input, tester, expectedText) {
spellcheckTestRunning = true;
testObject.step(() => {
// TODO(xiaochengh): Merge the following part with |assert_selection|.
/** @type {!Sample} */
const sample = typeof(input) === 'string' ? new Sample(input) : input;
testObject.sample = sample;
if (typeof(tester) === 'function') {
tester.call(window, sample.document);
} else if (typeof(tester) === 'string') {
const strings = tester.split(/ (.+)/);
sample.document.execCommand(strings[0], false, strings[1]);
} else {
assert_unreached(`Invalid tester: ${tester}`);
}
assert_not_equals(
window.testRunner, undefined,
'window.testRunner is required for automated spellcheck tests.');
assert_not_equals(
window.internals, undefined,
'window.internals is required for automated spellcheck tests.');
assert_equals(
verificationForCurrentTest, null,
'Internal error: previous test not verified yet');
verificationForCurrentTest = () => {
if (hasPendingSpellCheckRequest(sample.document))
return;
testObject.step(() => {
/** @type {!MarkerSerializer} */
const serializer = new MarkerSerializer({
spelling: '#',
grammar: '~'});
assert_equals(serializer.serialize(sample.document), expectedText);
testObject.done();
});
};
if (internals.idleTimeSpellCheckerState(sample.document) === 'HotModeRequested')
internals.runIdleTimeSpellChecker(sample.document);
if (testObject.properties[kNeedsFullCheck]) {
while (internals.idleTimeSpellCheckerState(sample.document) !== 'Inactive')
internals.runIdleTimeSpellChecker(sample.document);
}
// For a test that does not create new spell check request, a synchronous
// verification finishes everything.
verificationForCurrentTest();
});
}
add_result_callback(testObj => {
if (!testObj.properties[kIsSpellcheckTest])
return;
verificationForCurrentTest = null;
/** @type {boolean} */
var shouldRemoveSample = false;
if (testObj.status === testObj.PASS) {
if (testObj.properties[kCallback])
testObj.properties[kCallback](testObj.sample);
else
shouldRemoveSample = true;
} else {
if (window.testRunner)
shouldRemoveSample = true;
}
if (shouldRemoveSample)
testObj.sample.remove();
else
testObj.sample.keep();
// This is the earliest timing when a new spellcheck_test can be started.
spellcheckTestRunning = false;
/** @type {Object} */
const next = testQueue.shift();
if (next === undefined)
return;
invokeSpellcheckTest(next.testObject, next.input,
next.tester, next.expectedText);
});
/**
* @param {Object=} passedArgs
* @return {!Object}
*/
function getTestArguments(passedArgs) {
const args = {};
args[kIsSpellcheckTest] = true;
[kTitle, kCallback, kNeedsFullCheck].forEach(key => args[key] = undefined);
if (!passedArgs)
return args;
if (typeof(passedArgs) === 'string') {
args[kTitle] = passedArgs;
return args;
}
[kTitle, kCallback, kNeedsFullCheck].forEach(
key => args[key] = passedArgs[key]);
return args;
}
/**
* @param {!Sample|string} input
* @param {function(!Document)|string} tester
* @param {string} expectedText
* @param {Object=} opt_args
*/
function spellcheckTest(input, tester, expectedText, opt_args) {
/** @type {!Object} */
const args = getTestArguments(opt_args);
/** @type {!Test} */
const testObject = async_test(args[kTitle], args);
if (spellcheckTestRunning) {
testQueue.push({
testObject: testObject, input: input,
tester: tester, expectedText: expectedText});
return;
}
invokeSpellcheckTest(testObject, input, tester, expectedText);
}
if (window.testRunner) {
testRunner.setMockSpellCheckerEnabled(true);
testRunner.setSpellCheckResolvedCallback(() => {
if (verificationForCurrentTest)
verificationForCurrentTest();
});
}
// Export symbols
window.spellcheck_test = spellcheckTest;
})();