| // 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, '&') |
| .replace(/\u0022/g, '"') |
| .replace(/\u0027/g, '''); |
| 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; |
| })(); |