summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json2
-rw-r--r--.github/workflows/playwright.yml4
-rw-r--r--.github/workflows/publish-firefox-development.yml2
-rw-r--r--.github/workflows/scorecard.yml2
-rw-r--r--.vscode/settings.json5
-rw-r--r--dev/data/manifest-variants.json67
-rw-r--r--docs/permissions.md23
-rw-r--r--ext/js/background/request-builder.js294
-rw-r--r--ext/js/extension/environment.js16
-rw-r--r--ext/permissions.html47
-rw-r--r--ext/settings.html2
-rw-r--r--playwright.config.js12
-rw-r--r--test/playwright/global.setup.js32
-rw-r--r--test/playwright/global.teardown.js28
-rw-r--r--test/playwright/integration.spec.js91
-rw-r--r--test/playwright/playwright-util.js109
-rw-r--r--test/playwright/visual.spec.js38
17 files changed, 420 insertions, 354 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index 06a2be34..56bbcf09 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -461,6 +461,8 @@
},
{
"files": [
+ "integration.spec.js",
+ "playwright-util.js",
"visual.spec.js"
],
"env": {
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index c5bbc1cf..a0839fe3 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install CJK fonts
- uses: awalsh128/cache-apt-pkgs-action@1850ee53f6e706525805321a3f2f863dcf73c962 # v1.3.0
+ uses: awalsh128/cache-apt-pkgs-action@44c33b32f808cdddd5ac0366d70595ed63661ed8 # v1.3.1
with:
packages: fonts-ipafont-mincho
execute_install_scripts: true
@@ -66,6 +66,8 @@ jobs:
- name: "[PR] Generate new screenshots & compare against master"
id: playwright
+ env:
+ PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: 1
run: |
npx playwright test 2>&1 | tee ./playwright-output || true
continue-on-error: true
diff --git a/.github/workflows/publish-firefox-development.yml b/.github/workflows/publish-firefox-development.yml
index 7312bd56..1181277a 100644
--- a/.github/workflows/publish-firefox-development.yml
+++ b/.github/workflows/publish-firefox-development.yml
@@ -91,7 +91,7 @@ jobs:
git commit -a -m "${{ github.ref_name }}"
- name: Push changes
- uses: ad-m/github-push-action@29f05e01bb17e6f28228b47437e03a7b69e1f9ef # pin@master
+ uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@master
with:
branch: metadata
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 4db56a60..da73b593 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -34,7 +34,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
- uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0
+ uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d3738141..9318d9f6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,8 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
- "eslint.format.enable": true
+ "eslint.format.enable": true,
+ "playwright.env": {
+ "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS": 1
+ }
}
diff --git a/dev/data/manifest-variants.json b/dev/data/manifest-variants.json
index c9e7cd03..1eae2112 100644
--- a/dev/data/manifest-variants.json
+++ b/dev/data/manifest-variants.json
@@ -81,7 +81,6 @@
"clipboardWrite",
"unlimitedStorage",
"declarativeNetRequest",
- "webRequest",
"scripting",
"offscreen"
],
@@ -172,6 +171,27 @@
]
},
{
+ "name": "chrome-playwright",
+ "inherit": "chrome-dev",
+ "fileName": "yomitan-chrome-playwright.zip",
+ "modifications": [
+ {
+ "action": "remove",
+ "path": [
+ "optional_permissions"
+ ],
+ "item": "clipboardRead"
+ },
+ {
+ "action": "add",
+ "path": [
+ "permissions"
+ ],
+ "items": ["clipboardRead"]
+ }
+ ]
+ },
+ {
"name": "firefox",
"inherit": "base",
"fileName": "yomitan-firefox.zip",
@@ -230,44 +250,15 @@
"gecko": {
"id": "{cb7c0bec-7085-4f84-8422-7b55a7c4467c}",
"strict_min_version": "115.0"
+ },
+ "gecko_android": {
+ "strict_min_version": "115.0"
}
}
},
{
"action": "remove",
"path": [
- "optional_permissions"
- ],
- "item": "nativeMessaging"
- },
- {
- "action": "add",
- "path": [
- "permissions"
- ],
- "items": [
- "nativeMessaging"
- ]
- },
- {
- "action": "add",
- "path": [
- "permissions"
- ],
- "items": [
- "webRequestBlocking"
- ]
- },
- {
- "action": "remove",
- "path": [
- "permissions"
- ],
- "item": "declarativeNetRequest"
- },
- {
- "action": "remove",
- "path": [
"permissions"
],
"item": "offscreen"
@@ -349,13 +340,6 @@
"path": [
"permissions"
],
- "item": "webRequestBlocking"
- },
- {
- "action": "remove",
- "path": [
- "permissions"
- ],
"item": "offscreen"
},
{
@@ -382,6 +366,9 @@
],
"excludeFiles": [
"sw.js",
+ "offscreen.html",
+ "js/background/offscreen.js",
+ "js/background/offscreen-main.js",
"js/dom/simple-dom-parser.js",
"lib/parse5.js"
]
diff --git a/docs/permissions.md b/docs/permissions.md
index 57f9f0b2..b2b1a34c 100644
--- a/docs/permissions.md
+++ b/docs/permissions.md
@@ -9,26 +9,16 @@
`unlimitedStorage` is used to help prevent web browsers from unexpectedly
deleting dictionary data.
-* `webRequest` <br>
- Yomichan uses this permission to collect audio or create Anki notes using
- [AnkiConnect](https://ankiweb.net/shared/info/2055492159).
- It is also required to surface error information from failed requests.
-
-* `webRequestBlocking` _(Firefox only)_ <br>
- Yomichan uses this permission to ensure certain requests have valid and secure headers.
- This sometimes involves removing or changing the `Origin` request header,
- as this can be used to fingerprint browser configuration.
-
-* `declarativeNetRequest` _(Chrome only)_ <br>
+* `declarativeNetRequest` <br>
Yomichan uses this permission to ensure certain requests have valid and secure headers.
This sometimes involves removing or changing the `Origin` request header,
as this can be used to fingerprint browser configuration.
-* `scripting` _(Manifest V3 only)_ <br>
- Yomichan will sometimes need to inject stylesheets into webpages in order to
+* `scripting` <br>
+ Yomichan needs to inject content scripts and stylesheets into webpages in order to
properly display the search popup.
-* `offscreen` __(Chrome only)_ <br>
+* `offscreen` _(Chrome only)_ <br>
Yomitan uses this permission to create a secondary backend document that has DOM access, given that Manifest v3
service workers do not. Service workers can then reach out to out to this document in order to complete
actions that require access to DOM APIs, such as any that require clipboard access.
@@ -37,15 +27,14 @@
Yomichan supports simulating the `Ctrl+C` (copy to clipboard) keyboard shortcut
when a definitions popup is open and focused.
-* `clipboardRead` (optional) <br>
+* `clipboardRead` _(optional)_ <br>
Yomichan supports automatically opening a search window when Japanese text is copied to the clipboard
while the browser is running, depending on how certain settings are configured.
This allows Yomichan to support scanning text from external applications, provided there is a way
to copy text from those applications to the clipboard.
-* `nativeMessaging` (optional on Chrome) <br>
+* `nativeMessaging` _(optional, unavailable on Firefox for Android)_ <br>
Yomichan has the ability to communicate with an optional native messaging component in order to support
parsing large blocks of Japanese text using
[MeCab](https://en.wikipedia.org/wiki/MeCab).
The installation of this component is optional and is not included by default.
- This permission is optional on Chrome, but required on Firefox, because Firefox does not permit it to be optional.
diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js
index 663e242b..7ee89539 100644
--- a/ext/js/background/request-builder.js
+++ b/ext/js/background/request-builder.js
@@ -31,7 +31,6 @@ class RequestBuilder {
* Creates a new instance.
*/
constructor() {
- this._onBeforeSendHeadersExtraInfoSpec = ['blocking', 'requestHeaders', 'extraHeaders'];
this._textEncoder = new TextEncoder();
this._ruleIds = new Set();
}
@@ -42,6 +41,7 @@ class RequestBuilder {
async prepare() {
try {
await this._clearDynamicRules();
+ await this._clearSessionRules();
} catch (e) {
// NOP
}
@@ -54,15 +54,50 @@ class RequestBuilder {
* @returns {Promise<Response>} The response of the `fetch` call.
*/
async fetchAnonymous(url, init) {
- if (isObject(chrome.declarativeNetRequest)) {
- return await this._fetchAnonymousDeclarative(url, init);
+ const id = this._getNewRuleId();
+ const originUrl = this._getOriginURL(url);
+ url = encodeURI(decodeURI(url));
+
+ this._ruleIds.add(id);
+ try {
+ const addRules = [{
+ id,
+ priority: 1,
+ condition: {
+ urlFilter: `|${this._escapeDnrUrl(url)}|`,
+ resourceTypes: ['xmlhttprequest']
+ },
+ action: {
+ type: 'modifyHeaders',
+ requestHeaders: [
+ {
+ operation: 'remove',
+ header: 'Cookie'
+ },
+ {
+ operation: 'set',
+ header: 'Origin',
+ value: originUrl
+ }
+ ],
+ responseHeaders: [
+ {
+ operation: 'remove',
+ header: 'Set-Cookie'
+ }
+ ]
+ }
+ }];
+
+ await this._updateSessionRules({addRules});
+ try {
+ return await fetch(url, init);
+ } finally {
+ await this._tryUpdateSessionRules({removeRuleIds: [id]});
+ }
+ } finally {
+ this._ruleIds.delete(id);
}
- const originURL = this._getOriginURL(url);
- const headerModifications = [
- ['cookie', null],
- ['origin', {name: 'Origin', value: originURL}]
- ];
- return await this._fetchInternal(url, init, headerModifications);
}
/**
@@ -125,145 +160,56 @@ class RequestBuilder {
// Private
- async _fetchInternal(url, init, headerModifications) {
- const filter = {
- urls: [this._getMatchURL(url)],
- types: ['xmlhttprequest']
- };
-
- let requestId = null;
- const onBeforeSendHeadersCallback = (details) => {
- if (requestId !== null || details.url !== url) { return {}; }
- ({requestId} = details);
-
- if (headerModifications === null) { return {}; }
-
- const requestHeaders = details.requestHeaders;
- this._modifyHeaders(requestHeaders, headerModifications);
- return {requestHeaders};
- };
-
- let errorDetailsTimer = null;
- let {promise: errorDetailsPromise, resolve: errorDetailsResolve} = deferPromise();
- const onErrorOccurredCallback = (details) => {
- if (errorDetailsResolve === null || details.requestId !== requestId) { return; }
- if (errorDetailsTimer !== null) {
- clearTimeout(errorDetailsTimer);
- errorDetailsTimer = null;
- }
- errorDetailsResolve(details);
- errorDetailsResolve = null;
- };
-
- const eventListeners = [];
- const onBeforeSendHeadersExtraInfoSpec = (headerModifications !== null ? this._onBeforeSendHeadersExtraInfoSpec : []);
- this._addWebRequestEventListener(chrome.webRequest.onBeforeSendHeaders, onBeforeSendHeadersCallback, filter, onBeforeSendHeadersExtraInfoSpec, eventListeners);
- this._addWebRequestEventListener(chrome.webRequest.onErrorOccurred, onErrorOccurredCallback, filter, void 0, eventListeners);
+ async _clearSessionRules() {
+ const rules = await this._getSessionRules();
- try {
- return await fetch(url, init);
- } catch (e) {
- // onErrorOccurred is not always invoked by this point, so a delay is needed
- if (errorDetailsResolve !== null) {
- errorDetailsTimer = setTimeout(() => {
- errorDetailsTimer = null;
- if (errorDetailsResolve === null) { return; }
- errorDetailsResolve(null);
- errorDetailsResolve = null;
- }, 100);
- }
- const details = await errorDetailsPromise;
- if (details !== null) {
- const data = {details};
- this._assignErrorData(e, data);
- }
- throw e;
- } finally {
- this._removeWebRequestEventListeners(eventListeners);
- }
- }
+ if (rules.length === 0) { return; }
- _addWebRequestEventListener(target, callback, filter, extraInfoSpec, eventListeners) {
- try {
- for (let i = 0; i < 2; ++i) {
- try {
- if (typeof extraInfoSpec === 'undefined') {
- target.addListener(callback, filter);
- } else {
- target.addListener(callback, filter, extraInfoSpec);
- }
- break;
- } catch (e) {
- // Firefox doesn't support the 'extraHeaders' option and will throw the following error:
- // Type error for parameter extraInfoSpec (Error processing 2: Invalid enumeration value "extraHeaders") for [target].
- if (i === 0 && `${e.message}`.includes('extraHeaders') && Array.isArray(extraInfoSpec)) {
- const index = extraInfoSpec.indexOf('extraHeaders');
- if (index >= 0) {
- extraInfoSpec.splice(index, 1);
- continue;
- }
- }
- throw e;
- }
- }
- } catch (e) {
- console.log(e);
- return;
+ const removeRuleIds = [];
+ for (const {id} of rules) {
+ removeRuleIds.push(id);
}
- eventListeners.push({target, callback});
- }
- _removeWebRequestEventListeners(eventListeners) {
- for (const {target, callback} of eventListeners) {
- try {
- target.removeListener(callback);
- } catch (e) {
- console.log(e);
- }
- }
+ await this._updateSessionRules({removeRuleIds});
}
- _getMatchURL(url) {
- const url2 = new URL(url);
- return `${url2.protocol}//${url2.host}${url2.pathname}${url2.search}`.replace(/\*/g, '%2a');
+ _getSessionRules() {
+ return new Promise((resolve, reject) => {
+ chrome.declarativeNetRequest.getSessionRules((result) => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve(result);
+ }
+ });
+ });
}
- _getOriginURL(url) {
- const url2 = new URL(url);
- return `${url2.protocol}//${url2.host}`;
+ _updateSessionRules(options) {
+ return new Promise((resolve, reject) => {
+ chrome.declarativeNetRequest.updateSessionRules(options, () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
+ }
+ });
+ });
}
- _modifyHeaders(headers, modifications) {
- modifications = new Map(modifications);
-
- for (let i = 0, ii = headers.length; i < ii; ++i) {
- const header = headers[i];
- const name = header.name.toLowerCase();
- const modification = modifications.get(name);
- if (typeof modification === 'undefined') { continue; }
-
- modifications.delete(name);
-
- if (modification === null) {
- headers.splice(i, 1);
- --i;
- --ii;
- } else {
- headers[i] = modification;
- }
- }
-
- for (const header of modifications.values()) {
- if (header !== null) {
- headers.push(header);
- }
+ async _tryUpdateSessionRules(options) {
+ try {
+ await this._updateSessionRules(options);
+ return true;
+ } catch (e) {
+ return false;
}
}
async _clearDynamicRules() {
- if (!isObject(chrome.declarativeNetRequest)) { return; }
-
- const rules = this._getDynamicRules();
+ const rules = await this._getDynamicRules();
if (rules.length === 0) { return; }
@@ -275,53 +221,6 @@ class RequestBuilder {
await this._updateDynamicRules({removeRuleIds});
}
- async _fetchAnonymousDeclarative(url, init) {
- const id = this._getNewRuleId();
- const originUrl = this._getOriginURL(url);
- url = encodeURI(decodeURI(url));
-
- this._ruleIds.add(id);
- try {
- const addRules = [{
- id,
- priority: 1,
- condition: {
- urlFilter: `|${this._escapeDnrUrl(url)}|`,
- resourceTypes: ['xmlhttprequest']
- },
- action: {
- type: 'modifyHeaders',
- requestHeaders: [
- {
- operation: 'remove',
- header: 'Cookie'
- },
- {
- operation: 'set',
- header: 'Origin',
- value: originUrl
- }
- ],
- responseHeaders: [
- {
- operation: 'remove',
- header: 'Set-Cookie'
- }
- ]
- }
- }];
-
- await this._updateDynamicRules({addRules});
- try {
- return await this._fetchInternal(url, init, null);
- } finally {
- await this._tryUpdateDynamicRules({removeRuleIds: [id]});
- }
- } finally {
- this._ruleIds.delete(id);
- }
- }
-
_getDynamicRules() {
return new Promise((resolve, reject) => {
chrome.declarativeNetRequest.getDynamicRules((result) => {
@@ -348,15 +247,6 @@ class RequestBuilder {
});
}
- async _tryUpdateDynamicRules(options) {
- try {
- await this._updateDynamicRules(options);
- return true;
- } catch (e) {
- return false;
- }
- }
-
_getNewRuleId() {
let id = 1;
while (this._ruleIds.has(id)) {
@@ -367,6 +257,11 @@ class RequestBuilder {
return id;
}
+ _getOriginURL(url) {
+ const url2 = new URL(url);
+ return `${url2.protocol}//${url2.host}`;
+ }
+
_escapeDnrUrl(url) {
return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char));
}
@@ -380,25 +275,6 @@ class RequestBuilder {
return result;
}
- _assignErrorData(error, data) {
- try {
- error.data = data;
- } catch (e) {
- // On Firefox, assigning DOMException.data can fail in certain contexts.
- // https://bugzilla.mozilla.org/show_bug.cgi?id=1776555
- try {
- Object.defineProperty(error, 'data', {
- configurable: true,
- enumerable: true,
- writable: true,
- value: data
- });
- } catch (e2) {
- // NOP
- }
- }
- }
-
static _joinUint8Arrays(items, totalLength) {
if (items.length === 1) {
const {array, length} = items[0];
diff --git a/ext/js/extension/environment.js b/ext/js/extension/environment.js
index ec1e8612..ad5a19ae 100644
--- a/ext/js/extension/environment.js
+++ b/ext/js/extension/environment.js
@@ -31,8 +31,9 @@ class Environment {
}
async _loadEnvironmentInfo() {
- const browser = await this._getBrowser();
const os = await this._getOperatingSystem();
+ const browser = await this._getBrowser(os);
+
return {
browser,
platform: {os}
@@ -64,7 +65,7 @@ class Environment {
});
}
- async _getBrowser() {
+ async _getBrowser(os) {
try {
if (chrome.runtime.getURL('/').startsWith('ms-browser-extension://')) {
return 'edge-legacy';
@@ -76,17 +77,12 @@ class Environment {
// NOP
}
if (typeof browser !== 'undefined') {
- try {
- const info = await browser.runtime.getBrowserInfo();
- if (info.name === 'Fennec') {
- return 'firefox-mobile';
- }
- } catch (e) {
- // NOP
- }
if (this._isSafari()) {
return 'safari';
}
+ if (os === 'android') {
+ return 'firefox-mobile';
+ }
return 'firefox';
} else {
return 'chrome';
diff --git a/ext/permissions.html b/ext/permissions.html
index 9ede7d27..376a9de5 100644
--- a/ext/permissions.html
+++ b/ext/permissions.html
@@ -49,33 +49,6 @@
</div></div>
<div class="settings-item"><div class="settings-item-inner">
<div class="settings-item-left">
- <div class="settings-item-label"><code>webRequest</code></div>
- <div class="settings-item-description">
- <p>
- Yomitan uses this permission to collect audio or create Anki notes using
- <a href="https://ankiweb.net/shared/info/2055492159" target="_blank" rel="noopener noreferrer">AnkiConnect</a>.
- It is also required to surface error information from failed requests.
- </p>
- </div>
- </div>
- </div></div>
- <div class="settings-item" data-show-for-browser="firefox firefox-mobile"><div class="settings-item-inner">
- <div class="settings-item-left">
- <div class="settings-item-label"><code>webRequestBlocking</code></div>
- <div class="settings-item-description">
- <p>
- Yomitan uses this permission to ensure certain requests have valid and secure headers.
- This sometimes involves removing or changing the <code>Origin</code> request header,
- as this can be used to fingerprint browser configuration.
- </p>
- <p>
- Example: <code class="overflow-wrap">Origin: <span class="extension-id-example"></span></code>
- </p>
- </div>
- </div>
- </div></div>
- <div class="settings-item" data-show-for-browser="chrome edge"><div class="settings-item-inner">
- <div class="settings-item-left">
<div class="settings-item-label"><code>declarativeNetRequest</code></div>
<div class="settings-item-description">
<p>
@@ -89,11 +62,11 @@
</div>
</div>
</div></div>
- <div class="settings-item" data-show-for-manifest-version="3"><div class="settings-item-inner">
+ <div class="settings-item"><div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-label"><code>scripting</code></div>
<div class="settings-item-description">
- Yomitan will sometimes need to inject stylesheets into webpages in order to
+ Yomitan needs to inject content scripts and stylesheets into webpages in order to
properly display the search popup.
</div>
</div>
@@ -121,9 +94,9 @@
<label class="toggle"><input type="checkbox" class="permissions-toggle" data-required-permissions="clipboardRead"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div></div>
- <div class="settings-item"><div class="settings-item-inner">
+ <div class="settings-item" data-hide-for-browser="firefox-mobile"><div class="settings-item-inner">
<div class="settings-item-left">
- <div class="settings-item-label"><code>nativeMessaging</code> <span class="light" data-show-for-browser="chrome edge">(optional)</span></div>
+ <div class="settings-item-label"><code>nativeMessaging</code> <span class="light">(optional)</span></div>
<div class="settings-item-description">
Yomitan has the ability to communicate with an optional native messaging component in order to support
parsing large blocks of Japanese text using
@@ -135,18 +108,6 @@
<label class="toggle"><input type="checkbox" class="permissions-toggle" data-required-permissions="nativeMessaging"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div></div>
- <div class="settings-item" data-hide-for-manifest-version="3"><div class="settings-item-inner">
- <div class="settings-item-left">
- <div class="settings-item-label"><code>webNavigation</code> <span class="light">(optional)</span></div>
- <div class="settings-item-description">
- Yomitan may require this permission to inject content scripts for certain browsers
- if Google Docs accessibility mode is enabled.
- </div>
- </div>
- <div class="settings-item-right">
- <label class="toggle"><input type="checkbox" class="permissions-toggle" data-required-permissions="webNavigation"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
- </div>
- </div></div>
<div class="settings-item"><div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-label">Allow in private windows <span class="light">(optional)</span></div>
diff --git a/ext/settings.html b/ext/settings.html
index f1001f90..8d5f0a76 100644
--- a/ext/settings.html
+++ b/ext/settings.html
@@ -1372,7 +1372,7 @@
</p>
</div>
</div>
- <div class="settings-item advanced-only">
+ <div class="settings-item advanced-only" data-hide-for-browser="firefox-mobile">
<div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-invalid-indicator"></div>
diff --git a/playwright.config.js b/playwright.config.js
index 0f15ff59..11d79e72 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -62,8 +62,18 @@ module.exports = defineConfig({
/* Configure projects for major browsers */
projects: [
{
+ name: 'playwright setup',
+ testMatch: /global\.setup\.js/,
+ teardown: 'playwright teardown'
+ },
+ {
+ name: 'playwright teardown',
+ testMatch: /global\.teardown\.js/
+ },
+ {
name: 'chromium',
- use: {...devices['Desktop Chrome']}
+ use: {...devices['Desktop Chrome']},
+ dependencies: ['playwright setup']
}
// {
diff --git a/test/playwright/global.setup.js b/test/playwright/global.setup.js
new file mode 100644
index 00000000..442647f8
--- /dev/null
+++ b/test/playwright/global.setup.js
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const {test: setup} = require('@playwright/test');
+const {ManifestUtil} = require('../../dev/manifest-util');
+const {root} = require('./playwright-util');
+const path = require('path');
+const fs = require('fs');
+
+const manifestPath = path.join(root, 'ext/manifest.json');
+const copyManifestPath = path.join(root, 'ext/manifest-old.json');
+
+setup('use test manifest', () => {
+ const manifestUtil = new ManifestUtil();
+ const variant = manifestUtil.getManifest('chrome-playwright');
+ fs.renameSync(manifestPath, copyManifestPath);
+ fs.writeFileSync(manifestPath, ManifestUtil.createManifestString(variant).replace('$YOMITAN_VERSION', '0.0.0.0'));
+}); \ No newline at end of file
diff --git a/test/playwright/global.teardown.js b/test/playwright/global.teardown.js
new file mode 100644
index 00000000..2fb29ebe
--- /dev/null
+++ b/test/playwright/global.teardown.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const {test: teardown} = require('@playwright/test');
+const {root} = require('./playwright-util');
+const path = require('path');
+const fs = require('fs');
+
+const manifestPath = path.join(root, 'ext/manifest.json');
+const copyManifestPath = path.join(root, 'ext/manifest-old.json');
+
+teardown('bring back original manifest', () => {
+ fs.renameSync(copyManifestPath, manifestPath);
+}); \ No newline at end of file
diff --git a/test/playwright/integration.spec.js b/test/playwright/integration.spec.js
new file mode 100644
index 00000000..4e4663d6
--- /dev/null
+++ b/test/playwright/integration.spec.js
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const path = require('path');
+const {
+ test,
+ expect,
+ root,
+ mockModelFieldNames,
+ mockModelFieldsToAnkiValues,
+ expectedAddNoteBody,
+ mockAnkiRouteHandler,
+ writeToClipboardFromPage
+} = require('./playwright-util');
+const {createDictionaryArchive} = require('../../dev/util');
+
+test.beforeEach(async ({context}) => {
+ // wait for the on-install welcome.html tab to load, which becomes the foreground tab
+ const welcome = await context.waitForEvent('page');
+ welcome.close(); // close the welcome tab so our main tab becomes the foreground tab -- otherwise, the screenshot can hang
+});
+
+test('search clipboard', async ({page, extensionId}) => {
+ await page.goto(`chrome-extension://${extensionId}/search.html`);
+ await page.locator('#search-option-clipboard-monitor-container > label').click();
+ await page.waitForTimeout(200); // race
+
+ await writeToClipboardFromPage(page, 'あ');
+ await expect(page.locator('#search-textbox')).toHaveValue('あ');
+});
+
+test('anki add', async ({context, page, extensionId}) => {
+ // mock anki routes
+ let resolve;
+ const addNotePromise = new Promise((res) => {
+ resolve = res;
+ });
+ await context.route(/127.0.0.1:8765\/*/, (route) => {
+ mockAnkiRouteHandler(route);
+ const req = route.request();
+ if (req.url().includes('127.0.0.1:8765') && req.postDataJSON().action === 'addNote') {
+ resolve(req.postDataJSON());
+ }
+ });
+
+ // open settings
+ await page.goto(`chrome-extension://${extensionId}/settings.html`);
+
+ // load in test dictionary
+ const dictionary = createDictionaryArchive(path.join(root, 'test/data/dictionaries/valid-dictionary1'), 'valid-dictionary1');
+ const testDictionarySource = await dictionary.generateAsync({type: 'arraybuffer'});
+ await page.locator('input[id="dictionary-import-file-input"]').setInputFiles({name: 'valid-dictionary1.zip', buffer: Buffer.from(testDictionarySource)});
+ await expect(page.locator('id=dictionaries')).toHaveText('Dictionaries (1 installed, 1 enabled)', {timeout: 5 * 60 * 1000});
+
+ // connect to anki
+ await page.locator('.toggle', {has: page.locator('[data-setting="anki.enable"]')}).click();
+ await expect(page.locator('#anki-error-message')).toHaveText('Connected');
+
+ // prep anki deck
+ await page.locator('[data-modal-action="show,anki-cards"]').click();
+ await page.locator('select.anki-card-deck').selectOption('Mock Deck');
+ await page.locator('select.anki-card-model').selectOption('Mock Model');
+ for (const modelField of mockModelFieldNames) {
+ await page.locator(`[data-setting="anki.terms.fields.${modelField}"]`).fill(mockModelFieldsToAnkiValues[modelField]);
+ }
+ await page.locator('#anki-cards-modal > div > div.modal-footer > button:nth-child(2)').click();
+ await writeToClipboardFromPage(page, '読むの例文');
+
+ // add to anki deck
+ await page.goto(`chrome-extension://${extensionId}/search.html`);
+ await page.waitForTimeout(500); // race
+ await page.locator('#search-textbox').fill('読む');
+ await page.locator('#search-textbox').press('Enter');
+ await page.locator('[data-mode="term-kanji"]').click();
+ const addNoteReqBody = await addNotePromise;
+ expect(addNoteReqBody).toMatchObject(expectedAddNoteBody);
+}); \ No newline at end of file
diff --git a/test/playwright/playwright-util.js b/test/playwright/playwright-util.js
new file mode 100644
index 00000000..e28f16eb
--- /dev/null
+++ b/test/playwright/playwright-util.js
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const path = require('path');
+const {test: base, chromium} = require('@playwright/test');
+
+export const root = path.join(__dirname, '..', '..');
+
+export const test = base.extend({
+ context: async ({ }, use) => {
+ const pathToExtension = path.join(root, 'ext');
+ const context = await chromium.launchPersistentContext('', {
+ // headless: false,
+ args: [
+ '--headless=new',
+ `--disable-extensions-except=${pathToExtension}`,
+ `--load-extension=${pathToExtension}`
+ ]
+ });
+ await use(context);
+ await context.close();
+ },
+ extensionId: async ({context}, use) => {
+ let [background] = context.serviceWorkers();
+ if (!background) {
+ background = await context.waitForEvent('serviceworker');
+ }
+
+ const extensionId = background.url().split('/')[2];
+ await use(extensionId);
+ }
+});
+export const expect = test.expect;
+
+export const mockModelFieldNames = [
+ 'Word',
+ 'Reading',
+ 'Audio',
+ 'Sentence'
+];
+
+export const mockModelFieldsToAnkiValues = {
+ 'Word': '{expression}',
+ 'Reading': '{furigana-plain}',
+ 'Sentence': '{clipboard-text}',
+ 'Audio': '{audio}'
+};
+
+export const mockAnkiRouteHandler = (route) => {
+ const reqBody = route.request().postDataJSON();
+ const respBody = ankiRouteResponses[reqBody.action];
+ if (!respBody) {
+ return route.abort();
+ }
+ route.fulfill(respBody);
+};
+
+export const writeToClipboardFromPage = async (page, text) => {
+ await page.evaluate(`navigator.clipboard.writeText('${text}')`);
+};
+
+export const expectedAddNoteBody = {
+ 'action': 'addNote',
+ 'params':
+ {
+ 'note': {
+ 'fields': {
+ 'Word': '読む', 'Reading': '読[よ]む', 'Audio': '[sound:mock_audio.mp3]', 'Sentence': '読むの例文'
+ },
+ 'tags': ['yomitan'],
+ 'deckName': 'Mock Deck',
+ 'modelName': 'Mock Model',
+ 'options': {
+ 'allowDuplicate': false, 'duplicateScope': 'collection', 'duplicateScopeOptions': {
+ 'deckName': null, 'checkChildren': false, 'checkAllModels': false
+ }
+ }
+ }
+ }, 'version': 2
+};
+
+const baseAnkiResp = {
+ status: 200,
+ contentType: 'text/json'
+};
+
+const ankiRouteResponses = {
+ 'version': Object.assign({body: JSON.stringify(6)}, baseAnkiResp),
+ 'deckNames': Object.assign({body: JSON.stringify(['Mock Deck'])}, baseAnkiResp),
+ 'modelNames': Object.assign({body: JSON.stringify(['Mock Model'])}, baseAnkiResp),
+ 'modelFieldNames': Object.assign({body: JSON.stringify(mockModelFieldNames)}, baseAnkiResp),
+ 'canAddNotes': Object.assign({body: JSON.stringify([true, true])}, baseAnkiResp),
+ 'storeMediaFile': Object.assign({body: JSON.stringify('mock_audio.mp3')}, baseAnkiResp),
+ 'addNote': Object.assign({body: JSON.stringify(102312488912)}, baseAnkiResp)
+}; \ No newline at end of file
diff --git a/test/playwright/visual.spec.js b/test/playwright/visual.spec.js
index acb12e97..001f329f 100644
--- a/test/playwright/visual.spec.js
+++ b/test/playwright/visual.spec.js
@@ -16,40 +16,20 @@
*/
const path = require('path');
-const {test: base, chromium} = require('@playwright/test');
-const root = path.join(__dirname, '..', '..');
-
-export const test = base.extend({
- context: async ({ }, use) => {
- const pathToExtension = path.join(root, 'ext');
- const context = await chromium.launchPersistentContext('', {
- // headless: false,
- args: [
- '--headless=new',
- `--disable-extensions-except=${pathToExtension}`,
- `--load-extension=${pathToExtension}`
- ]
- });
- await use(context);
- await context.close();
- },
- extensionId: async ({context}, use) => {
- let [background] = context.serviceWorkers();
- if (!background) {
- background = await context.waitForEvent('serviceworker');
- }
- const extensionId = background.url().split('/')[2];
- await use(extensionId);
- }
-});
-const expect = test.expect;
+const {
+ test,
+ expect,
+ root
+} = require('./playwright-util');
-test('visual', async ({context, page, extensionId}) => {
+test.beforeEach(async ({context}) => {
// wait for the on-install welcome.html tab to load, which becomes the foreground tab
const welcome = await context.waitForEvent('page');
welcome.close(); // close the welcome tab so our main tab becomes the foreground tab -- otherwise, the screenshot can hang
+});
+test('visual', async ({page, extensionId}) => {
// open settings
await page.goto(`chrome-extension://${extensionId}/settings.html`);
@@ -117,4 +97,4 @@ test('visual', async ({context, page, extensionId}) => {
await screenshot(2, i, el, {x: 15, y: 15});
i++;
}
-});
+}); \ No newline at end of file