From bb9b2543e13a14e9ba52b0fcef91904fc1080442 Mon Sep 17 00:00:00 2001 From: Erek Speed Date: Tue, 22 Feb 2022 23:25:26 +0900 Subject: [PATCH] fix: Support Google Docs annotated canvas mode (#865) - Adds support for extracting annotated text - Unconditionally, enables annotated canvas mode in Google Docs - Adds new background.ts tests, since all previous tests were html fallback related. Fixes #852 --- .eslintrc.json | 5 + extension/background.ts | 13 - extension/docs-annotate-canvas.ts | 10 + extension/docs-html-fallback.ts | 23 - extension/rikaicontent.ts | 124 +++- extension/test/background_test.ts | 57 +- extension/test/docs-annotate-canvas_test.ts | 35 ++ extension/test/docs-html-fallback_test.ts | 40 -- extension/test/rikaicontent_test.ts | 661 +++++++++++++++++++- extension/test/test-augments.d.ts | 1 + package.json | 2 +- snowpack.config.cjs | 7 +- tsconfig.json | 2 +- web-test-runner.config.js | 2 +- 14 files changed, 853 insertions(+), 129 deletions(-) create mode 100644 extension/docs-annotate-canvas.ts delete mode 100644 extension/docs-html-fallback.ts create mode 100644 extension/test/docs-annotate-canvas_test.ts delete mode 100644 extension/test/docs-html-fallback_test.ts create mode 100644 extension/test/test-augments.d.ts diff --git a/.eslintrc.json b/.eslintrc.json index a8beb3531..1f6217542 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -62,6 +62,11 @@ { "allow": ["private-constructors"] } ], "tsdoc/syntax": "error" + }, + "settings": { + "import/resolver": { + "typescript": {} + } } }, { diff --git a/extension/background.ts b/extension/background.ts index 3b12570f3..a3955ad2d 100644 --- a/extension/background.ts +++ b/extension/background.ts @@ -39,19 +39,6 @@ chrome.runtime.onMessage.addListener(async (request, sender, response) => { } rcxMain.onTabSelect(sender.tab.id); break; - case 'forceDocsHtml?': - console.log('forceDocsHtml?'); - if (rcxMain.enabled === 1) { - response(true); - chrome.tabs.sendMessage(sender.tab!.id!, { - type: 'showPopup', - text: ` - rikaikun is forcing Google Docs to render using HTML instead of canvas.
- rikaikun can't work with canvas mode but if you need that mode, please disable rikaikun. - `, - }); - } - break; case 'xsearch': console.log('xsearch'); response(rcxMain.search(request.text, request.dictOption)); diff --git a/extension/docs-annotate-canvas.ts b/extension/docs-annotate-canvas.ts new file mode 100644 index 000000000..10c90eab1 --- /dev/null +++ b/extension/docs-annotate-canvas.ts @@ -0,0 +1,10 @@ +const injectedCode = `(function() {window['_docs_annotate_canvas_by_ext'] = '${chrome.runtime.id}';})();`; + +const script = document.createElement('script'); +script.textContent = injectedCode; +(document.head || document.documentElement).appendChild(script); +script.remove(); + +// Empty export to satisfy `isolatedModules` compiler flag and allow including in tests. +// Removed in production builds. +export {}; diff --git a/extension/docs-html-fallback.ts b/extension/docs-html-fallback.ts deleted file mode 100644 index 9881838aa..000000000 --- a/extension/docs-html-fallback.ts +++ /dev/null @@ -1,23 +0,0 @@ -function forceHtml(force: boolean) { - if (!force) { - return; - } - console.log( - 'rikaikun is forcing Docs to use HTML instead of canvas for rendering.' - ); - const injectedCode = `(function() {window['_docs_force_html_by_ext'] = '${chrome.runtime.id}';})();`; - - const script = document.createElement('script'); - - script.textContent = injectedCode; - - // Usually, `document.head` isn't guaranteed to be present when content_scripts run but in this case - // we're running inside a callback so it should be 100% safe. - document.head.appendChild(script); -} - -// This check allows the user to get newer Docs Canvas without disabling rikaikun. -// This delays when the forcing code is injected but it seems to be early enough in practice. -chrome.runtime.sendMessage({ type: 'forceDocsHtml?' }, forceHtml); - -export { forceHtml as TestOnlyForceHtml }; diff --git a/extension/rikaicontent.ts b/extension/rikaicontent.ts index 017c09e8e..46982616c 100644 --- a/extension/rikaicontent.ts +++ b/extension/rikaicontent.ts @@ -73,6 +73,9 @@ class RcxContent { private defaultDict = 2; private nextDict = 3; + private isGoogleDocPage = + document.querySelector('.kix-canvas-tile-content') !== null; + // Adds the listeners and stuff. enableTab(config: Config) { if (window.rikaichan === undefined) { @@ -709,6 +712,14 @@ class RcxContent { selEndList: { node: CharacterData; offset: number }[], maxLength: number ) { + // elements come from annotated google docs + if (rangeParent.nodeName === 'rect') { + return this.getTextFromGDoc( + rangeParent as SVGRectElement, + offset, + maxLength + ); + } if ( rangeParent.nodeName === 'TEXTAREA' || rangeParent.nodeName === 'INPUT' @@ -771,6 +782,52 @@ class RcxContent { // Hack because ro was coming out always 0 for some reason. lastRo = 0; + private getTextFromGDoc( + initialRect: SVGRectElement, + offset: number, + maxLength: number + ): string { + // Get text from initial rect. + const endIndex = Math.min(initialRect.ariaLabel.length, offset + maxLength); + let text = initialRect.ariaLabel.substring(offset, endIndex); + + // Append text from sibling and cousin rects. + const rectWalker = this.createGDocTreeWalker(initialRect); + let rectNode: Node | null; + rectWalker.currentNode = initialRect; + while ( + (rectNode = rectWalker.nextNode()) !== null && + text.length < maxLength + ) { + const rect = rectNode as SVGRectElement; + const rectEndIndex = Math.min( + rect.ariaLabel.length, + maxLength - text.length + ); + text += rect.ariaLabel.substring(0, rectEndIndex); + } + return text; + } + + private createGDocTreeWalker(rect: SVGRectElement): TreeWalker { + return document.createTreeWalker( + rect.parentNode!.parentNode!, // rect is in g is in svg always + NodeFilter.SHOW_ELEMENT, + { + acceptNode: function (node) { + if (node.nodeName === 'rect') { + return NodeFilter.FILTER_ACCEPT; + } + if (node.nodeName === 'g') { + // Don't include g elements but consider their children. + return NodeFilter.FILTER_SKIP; + } + return NodeFilter.FILTER_REJECT; + }, + } + ); + } + show(tdata: Rikaichan, dictOption: number) { const rp = tdata.prevRangeNode; let ro = tdata.prevRangeOfs! + tdata.uofs!; @@ -848,8 +905,10 @@ class RcxContent { const rp = tdata.prevRangeNode; // don't try to highlight form elements + // don't try to highlight google docs if ( rp && + !this.isGoogleDoc(rp) && ((tdata.config.highlight && !this.mDown && !('form' in tdata.prevTarget!)) || @@ -1010,10 +1069,16 @@ class RcxContent { // } - makeFake(real: HTMLTextAreaElement | HTMLInputElement) { + makeFake(real: HTMLTextAreaElement | HTMLInputElement | SVGRectElement) { const fake = document.createElement('div'); const realRect = real.getBoundingClientRect(); - fake.innerText = real.value; + let textValue = ''; + if (real instanceof SVGRectElement) { + textValue = real.ariaLabel; + } else { + textValue = real.value; + } + fake.innerText = textValue; fake.style.cssText = document.defaultView!.getComputedStyle( real, '' @@ -1069,6 +1134,7 @@ class RcxContent { } let fake; + let gdocRect: SVGRectElement | undefined; const tdata = window.rikaichan!; // per-tab data let range; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1081,6 +1147,14 @@ class RcxContent { HTMLSelectElement; // Put this in a try catch so that an exception here doesn't prevent editing due to div. try { + if (this.isGoogleDoc(eventTarget)) { + gdocRect = this.getRectUnderMouse(ev); + if (gdocRect) { + fake = this.makeFake(gdocRect); + fake.style.font = gdocRect.getAttribute('data-font-css')!; + } + } + if ( eventTarget.nodeName === 'TEXTAREA' || eventTarget.nodeName === 'INPUT' @@ -1088,9 +1162,9 @@ class RcxContent { fake = this.makeFake( eventTarget as HTMLTextAreaElement | HTMLInputElement ); + } + if (fake) { document.body.appendChild(fake); - fake.scrollTop = eventTarget.scrollTop; - fake.scrollLeft = eventTarget.scrollLeft; } // Calculate range and friends here after we've made our fake textarea/input divs. range = document.caretRangeFromPoint(ev.clientX, ev.clientY); @@ -1171,11 +1245,19 @@ class RcxContent { // For text nodes do special stuff // we make rp the text area and keep the offset the same // we give the text area data so it can act normal - if (fake) { + // Google Docs case also uses a fake so exclude it here + if (fake && !gdocRect) { rp = eventTarget; rp.data = rp.value; } + // Same as text case above except we use the found gdocRect instead of + // eventTarget + if (gdocRect) { + rp = gdocRect; + rp.data = rp.ariaLabel; + } + if (eventTarget === tdata.prevTarget && this.isVisible()) { // console.log("exit due to same target"); if (tdata.title) { @@ -1280,6 +1362,38 @@ class RcxContent { } } } + + private getRectUnderMouse(ev: MouseEvent): SVGRectElement | undefined { + const gElements = document.querySelectorAll('g'); + for (const gElement of gElements) { + if (this.mouseEventWasInElement(ev, gElement)) { + const rects = gElement.querySelectorAll('rect'); + for (const rectChild of rects) { + if (this.mouseEventWasInElement(ev, rectChild)) { + return rectChild; + } + } + } + } + return undefined; + } + + private mouseEventWasInElement(ev: MouseEvent, element: Element) { + const rect = element.getBoundingClientRect(); + return ( + ev.clientX >= rect.left && + ev.clientX <= rect.right && + ev.clientY >= rect.top && + ev.clientY <= rect.bottom + ); + } + + private isGoogleDoc(eventTarget: Element | CharacterData): boolean { + return ( + this.isGoogleDocPage && + (eventTarget.nodeName === 'svg' || eventTarget.nodeName === 'rect') + ); + } } const rcxContent = new RcxContent(); diff --git a/extension/test/background_test.ts b/extension/test/background_test.ts index aa391eec4..0c403e1e1 100644 --- a/extension/test/background_test.ts +++ b/extension/test/background_test.ts @@ -1,3 +1,4 @@ +import { Config } from '../configuration'; import { RcxMain } from '../rikaichan'; import { expect, use } from '@esm-bundle/chai'; import chrome from 'sinon-chrome'; @@ -23,62 +24,54 @@ describe('background.ts', function () { chrome.tabs.sendMessage.reset(); }); - describe('when sent "forceDocsHtml?" message', function () { - it('should not call response callback when rikaikun disabled', async function () { - rcxMain.enabled = 0; - const responseCallback = sinon.spy(); - - await sendMessageToBackground({ - type: 'forceDocsHtml?', - responseCallback: responseCallback, - }); - - expect(responseCallback).to.have.not.been.called; - }); - - it('should not send "showPopup" message when rikaikun disabled', async function () { - rcxMain.enabled = 0; + describe('when sent enable? message', function () { + it('should send "enable" message to tab', async function () { + rcxMain.enabled = 1; - await sendMessageToBackground({ - type: 'forceDocsHtml?', - }); + await sendMessageToBackground({ type: 'enable?' }); - expect(chrome.tabs.sendMessage).to.have.not.been.called; + expect(chrome.tabs.sendMessage).to.have.been.calledWithMatch( + /* tabId= */ sinon.match.any, + { + type: 'enable', + } + ); }); - it('should pass true to response callback when rikaikun enabled', async function () { + it('should respond to the same tab it received a message from', async function () { rcxMain.enabled = 1; - const responseCallback = sinon.spy(); + const tabId = 10; - await sendMessageToBackground({ - type: 'forceDocsHtml?', - responseCallback: responseCallback, - }); + await sendMessageToBackground({ tabId: tabId, type: 'enable?' }); - expect(responseCallback).to.have.been.calledOnceWith(true); + expect(chrome.tabs.sendMessage).to.have.been.calledWithMatch( + tabId, + /* message= */ sinon.match.any + ); }); - it('should send "showPopup" message when rikaikun enabled', async function () { + it('should send config in message to tab', async function () { rcxMain.enabled = 1; + rcxMain.config = { copySeparator: 'testValue' } as Config; - await sendMessageToBackground({ - type: 'forceDocsHtml?', - }); + await sendMessageToBackground({ type: 'enable?' }); expect(chrome.tabs.sendMessage).to.have.been.calledWithMatch( /* tabId= */ sinon.match.any, - { type: 'showPopup' } + { config: rcxMain.config } ); }); }); }); async function sendMessageToBackground({ + tabId = 0, type, responseCallback = () => { // Do nothing by default. }, }: { + tabId?: number; type: string; responseCallback?: (response: unknown) => void; }): Promise { @@ -86,7 +79,7 @@ async function sendMessageToBackground({ // eslint-disable-next-line @typescript-eslint/await-thenable await chrome.runtime.onMessage.addListener.yield( { type: type }, - { tab: { id: 0 } }, + { tab: { id: tabId } }, responseCallback ); return; diff --git a/extension/test/docs-annotate-canvas_test.ts b/extension/test/docs-annotate-canvas_test.ts new file mode 100644 index 000000000..dbadc3cf5 --- /dev/null +++ b/extension/test/docs-annotate-canvas_test.ts @@ -0,0 +1,35 @@ +import { expect } from '@esm-bundle/chai'; +import chrome from 'sinon-chrome'; +import sinon from 'sinon'; + +declare global { + interface Window { + _docs_annotate_canvas_by_ext?: string; + } +} + +describe('docs-annotate-canvas.ts', function () { + beforeEach(function () { + chrome.reset(); + sinon.reset(); + delete window._docs_annotate_canvas_by_ext; + }); + + it('should set special property to rikaikun extension ID when document.head exists', async function () { + chrome.runtime.id = 'test_special_id_head'; + + await import('../docs-annotate-canvas.js'); + + expect(window._docs_annotate_canvas_by_ext).to.equal(chrome.runtime.id); + }); + + it('should set special property to rikaikun extension ID with no document.head', async function () { + sinon.stub(document, 'head').value(undefined); + chrome.runtime.id = 'test_special_id_no_head'; + + // Added query string to force reloading module, requires updating test-augments.d.ts. + await import('../docs-annotate-canvas.js?no-head'); + + expect(window._docs_annotate_canvas_by_ext).to.equal(chrome.runtime.id); + }); +}); diff --git a/extension/test/docs-html-fallback_test.ts b/extension/test/docs-html-fallback_test.ts deleted file mode 100644 index 647c57f9b..000000000 --- a/extension/test/docs-html-fallback_test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import chrome from 'sinon-chrome'; - -declare global { - interface Window { - _docs_force_html_by_ext?: string; - } -} - -let forceHtmlCallback: (force: boolean) => void; - -describe('docs-html-fallback.ts after sending `forceDocsHtml?` message', function () { - before(async function () { - await import('../docs-html-fallback'); - forceHtmlCallback = chrome.runtime.sendMessage.args[0][1]; - }); - - beforeEach(function () { - chrome.reset(); - delete window._docs_force_html_by_ext; - }); - - describe('when `forceHtml` callback is called with `false`', function () { - it('should not add special property to window object', function () { - forceHtmlCallback(false); - - expect(window._docs_force_html_by_ext).to.be.undefined; - }); - }); - - describe('when `forceHtml` callback is called with `true`', function () { - it('should set special property to rikaikun extension ID', function () { - chrome.runtime.id = 'test_special_id'; - - forceHtmlCallback(true); - - expect(window._docs_force_html_by_ext).to.equal(chrome.runtime.id); - }); - }); -}); diff --git a/extension/test/rikaicontent_test.ts b/extension/test/rikaicontent_test.ts index abe2407a4..8a19e79d4 100644 --- a/extension/test/rikaicontent_test.ts +++ b/extension/test/rikaicontent_test.ts @@ -1,6 +1,8 @@ import { Config } from '../configuration'; +import { DictEntryData } from '../data'; import { TestOnlyRcxContent } from '../rikaicontent'; import { expect, use } from '@esm-bundle/chai'; +import { html, render } from 'lit-html'; import chrome from 'sinon-chrome'; import simulant from 'simulant'; import sinon from 'sinon'; @@ -9,14 +11,24 @@ import sinonChai from 'sinon-chai'; use(sinonChai); let rcxContent = new TestOnlyRcxContent(); +let root = document.createElement('div'); + +// Reset chrome to clear any API calls that happened during importing. +chrome.reset(); describe('RcxContent', function () { beforeEach(function () { + root = createAndAppendRoot(); + initializeRcxContent(); + }); + + afterEach(function () { chrome.reset(); - rcxContent = new TestOnlyRcxContent(); - // Default enable rcxContent since no tests care about that now. - rcxContent.enableTab({ showOnKey: '' } as Config); + sinon.restore(); + rcxContent.disableTab(); + root.remove(); }); + describe('.show', function () { describe('when given Japanese word interrupted with text wrapped by `display: none`', function () { it('sends "xsearch" message with invisible text omitted', function () { @@ -50,7 +62,6 @@ describe('RcxContent', function () { describe('when given Japanese word is interrupted with text wrapped by visible span', function () { it('sends "xsearch" message with all text included', function () { - const rcxContent = new TestOnlyRcxContent(); const span = insertHtmlIntoDomAndReturnFirstTextNode( 'test' ); @@ -87,10 +98,7 @@ describe('RcxContent', function () { '先生test' ) as HTMLSpanElement; - simulant.fire(span, 'mousemove', { - clientX: span.offsetLeft, - clientY: span.offsetTop, - }); + triggerMousemoveAtElementStart(span); // Tick the clock forward to account for the popup delay. clock.tick(1); @@ -99,13 +107,564 @@ describe('RcxContent', function () { text: '先生test', }); }); + + describe('inside text area', function () { + it('triggers xsearch message when above Japanese text', function () { + const clock = sinon.useFakeTimers(); + const textarea = insertHtmlIntoDomAndReturnFirstTextNode( + '' + ) as HTMLTextAreaElement; + + triggerMousemoveAtElementStart(textarea); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: '生test', + }); + }); + }); + + describe('with Google Docs annotated canvas', function () { + beforeEach(function () { + markDocumentWithGoogleDocsClass(); + // Reinitialize rcxContent now that special class is rendered. + initializeRcxContent(); + }); + + it('does not trigger xsearch when over svg but not rect', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + + triggerMousemoveAtElementCenter(root.querySelector('svg')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far left of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + const rect = root.querySelector('rect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left - 1), + clientY: Math.ceil(rect.getBoundingClientRect().top), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far right of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + const rect = root.querySelector('rect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left + 30), + clientY: Math.ceil(rect.getBoundingClientRect().top), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far up of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + const rect = root.querySelector('rect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left), + clientY: Math.ceil(rect.getBoundingClientRect().top - 1), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far down of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + const rect = root.querySelector('rect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left), + clientY: Math.ceil(rect.getBoundingClientRect().top + 18), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + describe('with mouse event inside ', function () { + // The x/y values have been adjusted to leave room for the + // mouse to be outside all rects while still in the element. + + it('does not trigger xsearch when too far left of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + `, + root + ); + const rect = root.querySelector('rect#startrect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left - 1), + clientY: Math.ceil(rect.getBoundingClientRect().top), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far right of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + `, + root + ); + const rect = root.querySelector('rect#startrect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left + 18), + clientY: Math.ceil(rect.getBoundingClientRect().top), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far up of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + `, + root + ); + const rect = root.querySelector('rect#startrect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left), + clientY: Math.ceil(rect.getBoundingClientRect().top - 1), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + + it('does not trigger xsearch when too far down of ', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + `, + root + ); + const rect = root.querySelector('rect#startrect')!; + + simulant.fire(rect, 'mousemove', { + clientX: Math.ceil(rect.getBoundingClientRect().left), + clientY: Math.ceil(rect.getBoundingClientRect().top + 18), + }); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.not.have.been.called; + }); + }); + + it('triggers xsearch message with svg target', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + + triggerMousemoveAtElementStart(root.querySelector('svg')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: 'あれ', + }); + }); + + it('triggers xsearch message with highlighted text target', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + + + + `, + root + ); + + triggerMousemoveAtElementStart(root.querySelector('rect#hlrect')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: 'あれる', + }); + }); + + it('triggers xsearch message including formatted text', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + `, + root + ); + + triggerMousemoveAtElementStart(root.querySelector('rect#startrect')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: '開始', + }); + }); + + it('triggers xsearch message including linebreak', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + + + `, + root + ); + + triggerMousemoveAtElementStart(root.querySelector('rect#startrect')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: '準備を', + }); + }); + + it('triggers xsearch message ignoring content in non tags', function () { + const clock = sinon.useFakeTimers(); + render( + html` + + + + + + 備を + + + `, + root + ); + + triggerMousemoveAtElementStart(root.querySelector('rect#startrect')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); + + expect(chrome.runtime.sendMessage).to.have.been.calledWithMatch({ + type: 'xsearch', + text: '準', + }); + }); + }); }); - describe('showPopup', function () { - afterEach(function () { - rcxContent.disableTab(); + describe('processEntry', function () { + describe('when highlight config option enabled', function () { + it('does not try to highlight text in Google Docs', function () { + markDocumentWithGoogleDocsClass(); + initializeRcxContent({ highlight: true }); + seedRcxContentWithGoogleDocsMouseMove(); + sinon.spy(window, 'getSelection'); + + rcxContent.processEntry({} as DictEntryData); + + expect(window.getSelection).to.not.have.been.called; + }); + + it('highlights normal text', function () { + initializeRcxContent({ highlight: true }); + seedRcxContentWithNormalMouseMove(); + const addRangeSpy = sinon.spy(window.getSelection()!, 'addRange'); + + rcxContent.processEntry({} as DictEntryData); + + expect(addRangeSpy).to.have.been.called.calledOnce; + }); }); + }); + describe('showPopup', function () { it('sets data-theme attribute of rikaikun window to config popupcolor value', function () { rcxContent.enableTab({ popupcolor: 'redtest' } as Config); @@ -132,10 +691,76 @@ describe('RcxContent', function () { }); }); +// Required if testing downstream methods which expect a proper hover event to have +// already been processed. +function seedRcxContentWithNormalMouseMove() { + const clock = sinon.useFakeTimers(); + const span = insertHtmlIntoDomAndReturnFirstTextNode( + '試す' + ) as HTMLSpanElement; + triggerMousemoveAtElementStart(span); + // Tick the clock forward to account for the popup delay. + clock.tick(1); +} +function seedRcxContentWithGoogleDocsMouseMove() { + const clock = sinon.useFakeTimers(); + render( + html` + + + + `, + root + ); + triggerMousemoveAtElementStart(root.querySelector('rect')!); + // Tick the clock forward to account for the popup delay. + clock.tick(1); +} + +function markDocumentWithGoogleDocsClass() { + render(html`
`, root); +} + +function initializeRcxContent(config = {} as Partial) { + // Disable first in case a test is calling this to override default behavior. + rcxContent.disableTab(); + rcxContent = new TestOnlyRcxContent(); + // showOnKey required for most tests to work. + rcxContent.enableTab({ showOnKey: '', ...config } as Config); +} + +function triggerMousemoveAtElementStart(element: Element) { + simulant.fire(element, 'mousemove', { + clientX: Math.ceil(element.getBoundingClientRect().left), + clientY: Math.ceil(element.getBoundingClientRect().top), + }); +} + +function triggerMousemoveAtElementCenter(element: Element) { + simulant.fire(element, 'mousemove', { + clientX: Math.ceil( + element.getBoundingClientRect().left + + element.getBoundingClientRect().width / 2 + ), + clientY: Math.ceil( + element.getBoundingClientRect().top + + element.getBoundingClientRect().height / 2 + ), + }); +} + function insertHtmlIntoDomAndReturnFirstTextNode(htmlString: string): Node { const template = document.createElement('template'); template.innerHTML = htmlString; - return document.body.appendChild(template.content.firstChild!); + return root.appendChild(template.content.firstChild!); } function executeShowForGivenNode( @@ -153,3 +778,15 @@ function executeShowForGivenNode( 0 ); } + +function createAndAppendRoot(): HTMLDivElement { + const existingRoot = document.querySelector('#root'); + if (existingRoot) { + existingRoot.remove(); + } + const root = document.createElement('div'); + root.setAttribute('id', 'root'); + root.style.position = 'relative'; + document.body.appendChild(root); + return root; +} diff --git a/extension/test/test-augments.d.ts b/extension/test/test-augments.d.ts new file mode 100644 index 000000000..d8cca72dc --- /dev/null +++ b/extension/test/test-augments.d.ts @@ -0,0 +1 @@ +declare module '*?no-head' {} diff --git a/package.json b/package.json index d7577fbd6..b16a16845 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prettier:format": "npm run prettier:base --silent -- --write", "stylelint:check": "stylelint '**/*.css' '**/*.ts' --ignore-path '.gitignore'", "stylelint:fix": "npm run stylelint:check --silent -- --fix", - "test": "wtr \"extension/test/*test*\"", + "test": "wtr \"extension/test/*_test.ts\"", "test:watch": "npm run test -- --watch", "update-db": "node --loader ts-node/esm utils/update-db.ts" }, diff --git a/snowpack.config.cjs b/snowpack.config.cjs index 4caece2a5..987d24fe0 100644 --- a/snowpack.config.cjs +++ b/snowpack.config.cjs @@ -13,11 +13,16 @@ const config = { 'snowpack-plugin-replace', { list: [ - //Remove test only export from rikaicontent + // Remove test only exports { from: /export.*TestOnly.*\n/, to: '', }, + // Remove empty exports + { + from: /export {};\n/, + to: '', + }, ], }, ], diff --git a/tsconfig.json b/tsconfig.json index d590de88d..0c197edd7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "experimentalDecorators": true, "rootDir": ".", "outDir": "dist", - "lib": ["ES2018", "DOM"], + "lib": ["ES2018", "DOM", "DOM.Iterable"], "esModuleInterop": true, "declaration": false, // ES2020 corresponds to Chrome 80+ diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 35c17752b..f77bbcc1d 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -112,7 +112,7 @@ class SpecReporter { /** @type {import('@web/test-runner').TestRunnerConfig} */ export default { coverageConfig: { - exclude: ['**/snowpack/**/*', '**/*.test.ts*'], + exclude: ['**/snowpack/**/*', '**/*_test.ts*'], }, browsers: [ puppeteerLauncher({