From 4150e42c056ea0eebb3f62016170d9607917beaa Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 30 Sep 2023 21:57:57 -0500 Subject: [PATCH] feat: `createContentScriptUi` helper (#143) --- demo/src/entrypoints/example-tsx.content.tsx | 12 +- demo/src/entrypoints/overlay.content/index.ts | 18 - demo/src/entrypoints/ui.content/index.ts | 24 ++ demo/src/entrypoints/ui.content/style.css | 11 + docs/guide/content-scripts.md | 2 +- e2e/tests/auto-imports.test.ts | 2 +- e2e/tests/manifest-content.test.ts | 92 +++++ package.json | 2 + pnpm-lock.yaml | 274 +++++++++++++- .../__tests__/createContentScriptUi.test.ts | 336 ++++++++++++++++++ src/client/createContentScriptUi.ts | 298 ++++++++++++++++ src/client/index.ts | 2 +- src/client/mountContentScriptUi.ts | 3 - src/client/utils/ContentScriptContext.ts | 17 +- .../content-script-entrypoint.ts | 5 +- src/core/server.ts | 8 +- src/core/types/external.ts | 2 +- src/core/utils/manifest.ts | 118 ++++-- vitest.setup.ts | 2 + vitest.workspace.ts | 1 + 20 files changed, 1161 insertions(+), 68 deletions(-) delete mode 100644 demo/src/entrypoints/overlay.content/index.ts create mode 100644 demo/src/entrypoints/ui.content/index.ts create mode 100644 demo/src/entrypoints/ui.content/style.css create mode 100644 src/client/__tests__/createContentScriptUi.test.ts create mode 100644 src/client/createContentScriptUi.ts delete mode 100644 src/client/mountContentScriptUi.ts create mode 100644 vitest.setup.ts diff --git a/demo/src/entrypoints/example-tsx.content.tsx b/demo/src/entrypoints/example-tsx.content.tsx index e1c694db..e030aa3a 100644 --- a/demo/src/entrypoints/example-tsx.content.tsx +++ b/demo/src/entrypoints/example-tsx.content.tsx @@ -2,7 +2,17 @@ import ReactDOM from 'react-dom/client'; export default defineContentScript({ matches: [''], - async main() { + async main(ctx) { + console.log(browser.runtime.id); + logId(); + + console.log('WXT MODE:', import.meta.env.MODE); + + const n = (Math.random() * 100).toFixed(1); + ctx.setInterval(() => { + console.log(n, browser.runtime.id); + }, 1e3); + const container = document.createElement('div'); document.body.append(container); diff --git a/demo/src/entrypoints/overlay.content/index.ts b/demo/src/entrypoints/overlay.content/index.ts deleted file mode 100644 index 0f9f79b9..00000000 --- a/demo/src/entrypoints/overlay.content/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import '../../common/style.css'; - -export default defineContentScript({ - matches: [''], - async main(ctx) { - console.log(browser.runtime.id); - logId(); - - console.log('WXT MODE:', import.meta.env.MODE); - - const n = (Math.random() * 100).toFixed(1); - ctx.setInterval(() => { - console.log(n, browser.runtime.id); - }, 1e3); - - mountContentScriptUi(); - }, -}); diff --git a/demo/src/entrypoints/ui.content/index.ts b/demo/src/entrypoints/ui.content/index.ts new file mode 100644 index 00000000..53058e01 --- /dev/null +++ b/demo/src/entrypoints/ui.content/index.ts @@ -0,0 +1,24 @@ +import '../../common/style.css'; +import './style.css'; + +export default defineContentScript({ + matches: ['https://*.duckduckgo.com/*'], + cssInjectionMode: 'ui', + + async main(ctx) { + const ui = await createContentScriptUi(ctx, { + name: 'demo-ui', + type: 'inline', + append: 'before', + anchor: 'form[role=search]', + mount: (container) => { + const app = document.createElement('div'); + app.textContent = 'Custom content script UI'; + container.append(app); + }, + }); + ui.mount(); + + setTimeout(ui.remove, 5000); + }, +}); diff --git a/demo/src/entrypoints/ui.content/style.css b/demo/src/entrypoints/ui.content/style.css new file mode 100644 index 00000000..ce526c21 --- /dev/null +++ b/demo/src/entrypoints/ui.content/style.css @@ -0,0 +1,11 @@ +:root { + color-scheme: dark; + color: indianred; +} +html { + background-color: black; +} + +div { + padding: 16px; +} diff --git a/docs/guide/content-scripts.md b/docs/guide/content-scripts.md index f5d4cd49..b14802bb 100644 --- a/docs/guide/content-scripts.md +++ b/docs/guide/content-scripts.md @@ -35,7 +35,7 @@ export default defineContentScript({ exclude: undefined | string[], // Configure how CSS is injected onto the page - cssInjectionMode: undefined | "manifest" | "manual", + cssInjectionMode: undefined | "manifest" | "manual" | "ui", main(ctx) { // Executed when content script is loaded diff --git a/e2e/tests/auto-imports.test.ts b/e2e/tests/auto-imports.test.ts index 203bb6bd..c9a6e648 100644 --- a/e2e/tests/auto-imports.test.ts +++ b/e2e/tests/auto-imports.test.ts @@ -18,10 +18,10 @@ describe('Auto Imports', () => { declare global { const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext'] const browser: typeof import('wxt/browser')['browser'] + const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi'] const defineBackground: typeof import('wxt/client')['defineBackground'] const defineConfig: typeof import('wxt')['defineConfig'] const defineContentScript: typeof import('wxt/client')['defineContentScript'] - const mountContentScriptUi: typeof import('wxt/client')['mountContentScriptUi'] } " `); diff --git a/e2e/tests/manifest-content.test.ts b/e2e/tests/manifest-content.test.ts index 1c46549f..9ed14ad5 100644 --- a/e2e/tests/manifest-content.test.ts +++ b/e2e/tests/manifest-content.test.ts @@ -390,6 +390,98 @@ describe('Manifest Content', () => { {\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}" `); }); + + it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV2', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content/style.css', + 'body { background-color: red; }', + ); + project.addFile( + 'entrypoints/content/index.ts', + `import "./style.css"; + + export default defineContentScript({ + matches: ["https://*.google.com/*"], + cssInjectionMode: "ui", + + main() {}, + });`, + ); + await project.build({ + manifestVersion: 2, + }); + + expect(await project.serializeFile('.output/chrome-mv2/manifest.json')) + .toMatchInlineSnapshot(` + ".output/chrome-mv2/manifest.json + ---------------------------------------- + {\\"manifest_version\\":2,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[\\"content-scripts/content.css\\"]}" + `); + }); + + it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV3', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content/style.css', + 'body { background-color: red; }', + ); + project.addFile( + 'entrypoints/content/index.ts', + `import "./style.css"; + + export default defineContentScript({ + matches: ["https://*.google.com/*"], + cssInjectionMode: "ui", + + main() {}, + });`, + ); + await project.build({ + manifestVersion: 3, + }); + + expect(await project.serializeFile('.output/chrome-mv3/manifest.json')) + .toMatchInlineSnapshot(` + ".output/chrome-mv3/manifest.json + ---------------------------------------- + {\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}]}" + `); + }); + }); + + it('should combine web accessible resources', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content/style.css', + 'body { background-color: red; }', + ); + project.addFile( + 'entrypoints/content/index.ts', + `import "./style.css"; + + export default defineContentScript({ + matches: ["https://*.google.com/*"], + cssInjectionMode: "ui", + + main() {}, + });`, + ); + project.setConfigFileConfig({ + manifest: { + web_accessible_resources: [ + { resources: ['one.png'], matches: ['https://one.com/*'] }, + ], + }, + }); + await project.build(); + + expect(await project.serializeFile('.output/chrome-mv3/manifest.json')) + .toMatchInlineSnapshot(` + ".output/chrome-mv3/manifest.json + ---------------------------------------- + {\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"web_accessible_resources\\":[{\\"resources\\":[\\"one.png\\"],\\"matches\\":[\\"https://one.com/*\\"]},{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}],\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}" + `); }); it('should respect the transformManifest option', async () => { diff --git a/package.json b/package.json index d8192171..5ed1ae82 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "@types/webextension-polyfill": "^0.10.1", "@webext-core/fake-browser": "^1.2.2", + "@webext-core/isolated-element": "^1.0.4", "@webext-core/match-patterns": "^1.0.2", "async-mutex": "^0.4.0", "c12": "^1.4.2", @@ -102,6 +103,7 @@ "@types/prompts": "^2.4.4", "@vitest/coverage-v8": "^0.34.1", "execa": "^7.2.0", + "jsdom": "^22.1.0", "lint-staged": "^14.0.0", "lodash.merge": "^4.6.2", "npm-run-all": "^4.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf573db..fc111fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@webext-core/fake-browser': specifier: ^1.2.2 version: 1.2.2 + '@webext-core/isolated-element': + specifier: ^1.0.4 + version: 1.0.4 '@webext-core/match-patterns': specifier: ^1.0.2 version: 1.0.2 @@ -114,6 +117,9 @@ importers: execa: specifier: ^7.2.0 version: 7.2.0 + jsdom: + specifier: ^22.1.0 + version: 22.1.0 lint-staged: specifier: ^14.0.0 version: 14.0.1 @@ -146,7 +152,7 @@ importers: version: 1.0.0-rc.10(@types/node@20.5.9) vitest: specifier: ^0.34.1 - version: 0.34.3 + version: 0.34.3(jsdom@22.1.0) vitest-mock-extended: specifier: ^1.1.4 version: 1.2.1(typescript@5.1.6)(vitest@0.34.3) @@ -967,6 +973,11 @@ packages: defer-to-connect: 2.0.1 dev: false + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + /@ts-morph/common@0.20.0: resolution: {integrity: sha512-7uKjByfbPpwuzkstL3L5MQyuXPSKdoNG93Fmi2JoDcTf3pEP731JdRFAduRVkOs8oqxPsXKA+ScrWkdQ8t/I+Q==} dependencies: @@ -1085,7 +1096,7 @@ packages: std-env: 3.3.3 test-exclude: 6.0.0 v8-to-istanbul: 9.1.0 - vitest: 0.34.3 + vitest: 0.34.3(jsdom@22.1.0) transitivePeerDependencies: - supports-color dev: true @@ -1300,12 +1311,20 @@ packages: lodash.merge: 4.6.2 dev: false + /@webext-core/isolated-element@1.0.4: + resolution: {integrity: sha512-jbkG+7b6Ty4fNo2o53rokCTkNRinI/0tneJSyuUCJfPzAFtyylF3IxH/WhXykXC+1Ca8ouQnjbEcpYt9IFNcUg==} + dev: false + /@webext-core/match-patterns@1.0.2: resolution: {integrity: sha512-ApmXwA2HRCgt/vXaZw8NfyV3hBqsyO7wAork4qgH+AtCJW4uRxHbMruSKJ0Flh/yOGMbrbLixd3E9Yw5dmGygw==} dependencies: '@webcomponents/webcomponentsjs': 2.8.0 dev: false + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + dev: true + /acorn-walk@8.2.0: resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} engines: {node: '>=0.4.0'} @@ -1328,7 +1347,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: false /algoliasearch@4.19.1: resolution: {integrity: sha512-IJF5b93b2MgAzcE/tuzW0yOPnuUyRgGAtaPv5UUywXM8kzqfdwZTO4sPJBzoGz1eOy6H9uEchsJsBFTELZSu+g==} @@ -1443,6 +1461,10 @@ packages: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: false + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true + /at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -1719,6 +1741,13 @@ packages: /colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: true + /commander@11.0.0: resolution: {integrity: sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==} engines: {node: '>=16'} @@ -1831,10 +1860,26 @@ packages: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} dev: false + /cssstyle@3.0.0: + resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} + engines: {node: '>=14'} + dependencies: + rrweb-cssom: 0.6.0 + dev: true + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true + /data-urls@4.0.0: + resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} + engines: {node: '>=14'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + dev: true + /debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} dev: false @@ -1861,6 +1906,10 @@ packages: dependencies: ms: 2.1.2 + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + /decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1902,6 +1951,11 @@ packages: resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} dev: false + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: true + /destr@2.0.0: resolution: {integrity: sha512-FJ9RDpf3GicEBvzI3jxc2XhHzbqD8p4ANw/1kPsFBfTvP1b7Gn/Lg1vO7R9J4IVgoMbyUmFrFGZafJ1hPZpvlg==} dev: false @@ -1930,6 +1984,13 @@ packages: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} dev: false + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + dependencies: + webidl-conversions: 7.0.0 + dev: true + /domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -1983,7 +2044,6 @@ packages: /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - dev: false /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -2231,6 +2291,15 @@ packages: engines: {node: '>= 14.17'} dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + /fs-extra@11.1.1: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} engines: {node: '>=14.14'} @@ -2500,6 +2569,13 @@ packages: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -2521,6 +2597,17 @@ packages: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} dev: false + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /http2-wrapper@2.2.0: resolution: {integrity: sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==} engines: {node: '>=10.19.0'} @@ -2537,7 +2624,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: false /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} @@ -2549,6 +2635,13 @@ packages: engines: {node: '>=14.18.0'} dev: true + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false @@ -2746,6 +2839,10 @@ packages: engines: {node: '>=8'} dev: false + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -2892,6 +2989,44 @@ packages: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: false + /jsdom@22.1.0: + resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==} + engines: {node: '>=16'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + cssstyle: 3.0.0 + data-urls: 4.0.0 + decimal.js: 10.4.3 + domexception: 4.0.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.7 + parse5: 7.1.2 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.3 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + ws: 8.13.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: false @@ -3145,6 +3280,18 @@ packages: braces: 3.0.2 picomatch: 2.3.1 + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3388,6 +3535,10 @@ packages: boolbase: 1.0.0 dev: false + /nwsapi@2.2.7: + resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3507,6 +3658,12 @@ packages: type-fest: 3.13.1 dev: false + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + /path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} dev: true @@ -3659,6 +3816,10 @@ packages: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: false + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} @@ -3671,6 +3832,10 @@ packages: escape-goat: 4.0.0 dev: false + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3786,6 +3951,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + /resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} dev: false @@ -3876,6 +4045,10 @@ packages: optionalDependencies: fsevents: 2.3.2 + /rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3899,6 +4072,10 @@ packages: is-regex: 1.1.4 dev: true + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + /sass@1.64.0: resolution: {integrity: sha512-m7YtAGmQta9uANIUJwXesAJMSncqH+3INc8kdVXs6eV6GUC8Qu2IYKQSN8PRLgiQfpca697G94klm2leYMxSHw==} engines: {node: '>=14.0.0'} @@ -3913,6 +4090,13 @@ packages: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} dev: false + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -4257,6 +4441,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} dev: true @@ -4329,12 +4517,29 @@ packages: dependencies: is-number: 7.0.0 + /tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + /tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} dependencies: punycode: 2.3.0 dev: true + /tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + dependencies: + punycode: 2.3.0 + dev: true + /tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4494,6 +4699,11 @@ packages: crypto-random-string: 4.0.0 dev: false + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + /universalify@1.0.0: resolution: {integrity: sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==} engines: {node: '>= 10.0.0'} @@ -4533,6 +4743,13 @@ packages: xdg-basedir: 5.1.0 dev: false + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false @@ -4665,10 +4882,10 @@ packages: dependencies: ts-essentials: 9.3.2(typescript@5.1.6) typescript: 5.1.6 - vitest: 0.34.3 + vitest: 0.34.3(jsdom@22.1.0) dev: true - /vitest@0.34.3: + /vitest@0.34.3(jsdom@22.1.0): resolution: {integrity: sha512-7+VA5Iw4S3USYk+qwPxHl8plCMhA5rtfwMjgoQXMT7rO5ldWcdsdo3U1QD289JgglGK4WeOzgoLTsGFu6VISyQ==} engines: {node: '>=v14.18.0'} hasBin: true @@ -4712,6 +4929,7 @@ packages: cac: 6.7.14 chai: 4.3.7 debug: 4.3.4 + jsdom: 22.1.0 local-pkg: 0.4.3 magic-string: 0.30.2 pathe: 1.1.1 @@ -4766,6 +4984,13 @@ packages: '@vue/shared': 3.3.4 dev: true + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + /watchpack@2.4.0: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} @@ -4815,6 +5040,11 @@ packages: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} dev: true + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + /webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -4824,6 +5054,26 @@ packages: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} dev: false + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@12.0.1: + resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} + engines: {node: '>=14'} + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + dev: true + /whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} dependencies: @@ -4940,13 +5190,17 @@ packages: optional: true utf-8-validate: optional: true - dev: false /xdg-basedir@5.1.0: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} dev: false + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + /xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -4960,6 +5214,10 @@ packages: engines: {node: '>=4.0'} dev: false + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} diff --git a/src/client/__tests__/createContentScriptUi.test.ts b/src/client/__tests__/createContentScriptUi.test.ts new file mode 100644 index 00000000..971e158b --- /dev/null +++ b/src/client/__tests__/createContentScriptUi.test.ts @@ -0,0 +1,336 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ContentScriptContext, createContentScriptUi } from '..'; +import { createIsolatedElement } from '@webext-core/isolated-element'; +import { mock } from 'vitest-mock-extended'; +import { browser } from '../browser'; + +vi.mock('webextension-polyfill', () => + vi.importActual('../virtual-modules/fake-browser'), +); + +vi.mock('@webext-core/isolated-element', async () => { + const { vi } = await import('vitest'); + return { + createIsolatedElement: vi.fn(), + }; +}); +const createIsolatedElementMock = vi.mocked(createIsolatedElement); + +const testApp = (container: Element) => { + const app = document.createElement('div'); + app.textContent = 'App'; + container.append(app); +}; + +const createCtx = () => new ContentScriptContext('test'); + +const fetch = vi.fn(); + +describe('createContentScriptUi', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+

one

+

two

+

+
+ `; + + window.fetch = fetch; + fetch.mockResolvedValue({ text: () => Promise.resolve('') }); + + createIsolatedElementMock.mockImplementation(async (config) => { + const parentElement = document.createElement(config.name); + const isolatedElement = document.createElement('html'); + parentElement.append(isolatedElement); + + return { + isolatedElement, + parentElement: parentElement, + shadow: mock({ + querySelector: (selector: string) => { + if (selector === 'html') return isolatedElement; + }, + }), + }; + }); + }); + + describe('css', () => { + it('should load the CSS for the current entrypoint when cssInjectionMode=ui', async () => { + fetch.mockResolvedValue({ text: () => Promise.resolve('body {}') }); + const ctx = new ContentScriptContext('test', { + matches: [], + cssInjectionMode: 'ui', + }); + + await createContentScriptUi(ctx, { + name: 'test', + type: 'inline', + mount: testApp, + }); + + expect(fetch).toBeCalledTimes(1); + expect(fetch).toBeCalledWith( + `chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`, + ); + expect(createIsolatedElementMock).toBeCalledTimes(1); + expect(createIsolatedElementMock).toBeCalledWith( + expect.objectContaining({ + css: { + textContent: 'body {}', + }, + }), + ); + }); + + it('should still load the UI when fetch fails to load CSS file when cssInjectionMode=ui', async () => { + const error = Error('Test fetch error'); + fetch.mockRejectedValue(error); + const ctx = new ContentScriptContext('test', { + matches: [], + cssInjectionMode: 'ui', + }); + + await createContentScriptUi(ctx, { + name: 'test', + type: 'inline', + mount: testApp, + }); + + expect(fetch).toBeCalledTimes(1); + expect(fetch).toBeCalledWith( + `chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`, + ); + expect(createIsolatedElementMock).toBeCalledTimes(1); + expect(createIsolatedElementMock).toBeCalledWith( + expect.objectContaining({ + css: { + textContent: '', + }, + }), + ); + }); + + it.each(['manifest', 'manual'] as const)( + 'should not fetch CSS when cssInjectionMode=%s', + async (cssInjectionMode) => { + const ctx = new ContentScriptContext('test', { + matches: [], + cssInjectionMode, + }); + + await createContentScriptUi(ctx, { + name: 'test', + type: 'inline', + mount: testApp, + }); + + expect(fetch).not.toBeCalled(); + }, + ); + }); + + describe('mount', () => { + describe('append option', () => { + it.each([undefined, 'last' as const])( + 'should append the element as the last child to the anchor when append=%s', + async (append) => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#parent', + append, + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(4)')?.tagName, + ).toEqual('TEST-APP'); + }, + ); + + it('should append the element as the first child to the anchor when append=first', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#parent', + append: 'first', + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(1)')?.tagName, + ).toEqual('TEST-APP'); + }); + + it('should append the element normally when append=first but there are no other children', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#three', + append: 'first', + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#three > :nth-child(1)')?.tagName, + ).toEqual('TEST-APP'); + }); + + it('should replace the anchor with the element when append=replace', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#two', + append: 'replace', + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(2)')?.tagName, + ).toEqual('TEST-APP'); + }); + + it('should append the element after the anchor when append=before', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#two', + append: 'before', + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(2)')?.tagName, + ).toEqual('TEST-APP'); + }); + + it('should append the element after the anchor when append=after', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#two', + append: 'after', + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(3)')?.tagName, + ).toEqual('TEST-APP'); + }); + + it('should apply a custom function', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: '#three', + append: (anchor, ui) => anchor.replaceWith(ui), + mount: testApp, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(3)')?.tagName, + ).toEqual('TEST-APP'); + }); + }); + + it('should default the anchor to the body when unset', async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + mount: testApp, + }); + ui.mount(); + + expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual( + 'TEST-APP', + ); + }); + + it.each(['#four', () => document.querySelector('#four')])( + "should throw an error if the anchor doesn't exist", + async (anchor) => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test', + type: 'inline', + anchor, + mount: testApp, + }); + + expect(ui.mount).toThrow( + 'Failed to mount content script ui: could not find anchor element', + ); + }, + ); + }); + + describe('remove', () => { + it("should not fail if the ui hasn't been mounted", async () => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + mount: testApp, + }); + ui.remove(); + }); + + it('should automatically remove the UI when the context is invalidated', async () => { + const ctx = createCtx(); + const ui = await createContentScriptUi(ctx, { + name: 'test-app', + type: 'inline', + mount: testApp, + }); + ui.mount(); + + expect(document.querySelector('test-app')).toBeDefined(); + + ctx.abort(); + expect(document.querySelector('test-app')).toBeNull(); + }); + }); + + describe('type', () => { + it.each(['inline', 'overlay', 'modal'] as const)( + 'should render type=%s', + async (type) => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type, + mount: testApp, + }); + ui.mount(); + + expect(document.querySelector('test-app')).toBeDefined(); + }, + ); + }); + + describe('anchor', () => { + it.each([ + () => '#two', + () => () => '#two', + () => document.querySelector('#two'), + () => () => document.querySelector('#two'), + ])('should render anchor=%s', async (getAnchor) => { + const ui = await createContentScriptUi(createCtx(), { + name: 'test-app', + type: 'inline', + anchor: getAnchor(), + mount: testApp, + }); + ui.mount(); + + expect(document.querySelector('test-app')).toBeDefined(); + }); + }); +}); diff --git a/src/client/createContentScriptUi.ts b/src/client/createContentScriptUi.ts new file mode 100644 index 00000000..04f9d18e --- /dev/null +++ b/src/client/createContentScriptUi.ts @@ -0,0 +1,298 @@ +import { createIsolatedElement } from '@webext-core/isolated-element'; +import { browser } from './browser'; +import { logger } from './utils/logger'; +import { ContentScriptContext } from '.'; + +/** + * Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM + * when the content script's context is invalidated. + * + * See for full documentation. + * + * @example + * // entrypoints/example-ui.content/index.ts + * import "./style.css" + * + * export default defineContentScript({ + * matches: ["*://*.google.com/*"], + * cssInjectionMode: "ui", + * + * async main(ctx) { + * const ui = await createContentScriptUi(ctx, { + * name: "example-overlay", + * type: "modal", + * mount(container) { + * const app = document.createElement("div"); + * app.textContent = "Content Script UI"; + * container.append(app); + * } + * }) + * ui.mount(); + * } + * }) + */ +export async function createContentScriptUi( + ctx: ContentScriptContext, + options: ContentScriptUiOptions, +): Promise> { + const css = [options.css ?? '']; + if (ctx.options?.cssInjectionMode === 'ui') { + css.push(await loadCss()); + } + + const { + isolatedElement: uiContainer, + parentElement: shadowHost, + shadow, + } = await createIsolatedElement({ + name: options.name, + css: { + textContent: css.join('\n').trim(), + }, + mode: 'open', + }); + + const getAnchor = (): Element | undefined => { + if (options.anchor == null) return document.body; + + let resolved = + typeof options.anchor === 'function' ? options.anchor() : options.anchor; + if (typeof resolved === 'string') + return document.querySelector(resolved) ?? undefined; + return resolved ?? undefined; + }; + + let mounted: T; + + const mount = () => { + const anchor = getAnchor(); + if (anchor == null) + throw Error( + 'Failed to mount content script ui: could not find anchor element', + ); + + // Mount UI inside shadow root + mounted = options.mount(uiContainer); + + // Add shadow root element to DOM + switch (options.append) { + case undefined: + case 'last': + anchor.append(shadowHost); + break; + case 'first': + if (anchor.firstChild) { + anchor.insertBefore(shadowHost, anchor.firstChild); + } else { + anchor.append(shadowHost); + } + break; + case 'replace': + anchor.replaceWith(shadowHost); + break; + case 'after': + anchor.replaceWith(anchor, shadowHost); + break; + case 'before': + anchor.replaceWith(shadowHost, anchor); + break; + default: + options.append(anchor, shadowHost); + break; + } + + // Apply types + if (options.type !== 'inline') { + if (options.zIndex != null) + shadowHost.style.zIndex = String(options.zIndex); + + shadowHost.style.overflow = 'visible'; + shadowHost.style.position = 'relative'; + shadowHost.style.width = '0'; + shadowHost.style.height = '0'; + shadowHost.style.display = 'block'; + + const html = shadow.querySelector('html')!; + // HTML doesn't exist in tests + if (options.type === 'overlay') { + html.style.position = 'absolute'; + if (options.alignment?.startsWith('bottom-')) html.style.bottom = '0'; + else html.style.top = '0'; + + if (options.alignment?.endsWith('-right')) html.style.right = '0'; + else html.style.left = '0'; + } else { + html.style.position = 'fixed'; + html.style.top = '0'; + html.style.bottom = '0'; + html.style.left = '0'; + html.style.right = '0'; + } + } + }; + + const remove = () => { + // Detatch shadow root from DOM + shadowHost.remove(); + // Cleanup mounted state + options.onRemove?.(mounted); + // Remove children from uiContainer + while (uiContainer.lastChild) + uiContainer.removeChild(uiContainer.lastChild); + }; + + ctx.onInvalidated(remove); + + return { + shadow, + shadowHost, + uiContainer, + mount, + remove, + mounted: mounted!, + }; +} + +/** + * Load the CSS for the current entrypoint. + */ +async function loadCss(): Promise { + const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`); + try { + const res = await fetch(url); + const css = await res.text(); + + // Replace :root selectors with :host since we're in a shadow root + return css.replaceAll(':root', ':host'); + } catch (err) { + logger.warn( + `Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`, + err, + ); + return ''; + } +} + +export interface ContentScriptUi { + /** + * The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element + * that get's added to the DOM. This element's style is not isolated from the webpage. + */ + shadowHost: HTMLElement; + /** + * The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted + * inside this `HTMLElement`. + */ + uiContainer: HTMLElement; + /** + * The shadow root performing the isolation. + */ + shadow: ShadowRoot; + /** + * Custom data returned from the `options.mount` function. + */ + mounted: T; + /** + * Function that mounts or remounts the UI on the page. + */ + mount: () => void; + /** + * Function that removes the UI from the webpage. + */ + remove: () => void; +} + +interface BaseContentScriptUiOptions { + /** + * The name of the custom component used to host the ShadowRoot. Must be kebab-case. + */ + name: string; + /** + * In combination with `anchor`, decide how to add the UI to the DOM. + * + * - `"last"` (default) - Add the UI as the last child of the `anchor` element + * - `"first"` - Add the UI as the last child of the `anchor` element + * - `"replace"` - Replace the `anchor` element with the UI. + * - `"before"` - Add the UI as the sibling before the `anchor` element + * - `"after"` - Add the UI as the sibling after the `anchor` element + * - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM + */ + append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void); + /** + * A CSS selector, element, or function that returns one of the two. Along with `append`, the + * `anchor` dictates where in the page the UI will be added. + */ + anchor?: + | string + | Element + | null + | undefined + | (() => string | Element | null | undefined); + /** + * Callback executed when mounting the UI. This function should create and append the UI to the + * `container` element. It is called every time `ui.mount()` is called + * + * Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback. + */ + mount: (container: Element) => T; + /** + * Callback called when the UI is removed from the webpage. Use to cleanup your UI, like + * unmounting your vue or react apps. + */ + onRemove?: (mounted: T) => void; + /** + * Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've + * set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need + * to pass those styles in here. This is for any additional styles not in the imported CSS. + * + * See for more info. + */ + css?: string; +} + +export type OverlayContentScriptUiOptions = BaseContentScriptUiOptions & { + type: 'overlay'; + /** + * When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies + * which corner is aligned with that 0x0 pixel space. + * + * @default "top-left" + */ + alignment?: ContentScriptUiOverlayAlignment; + /** + * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website + * content. + */ + zIndex?: number; +}; + +export type ModalContentScriptUiOptions = BaseContentScriptUiOptions & { + type: 'modal'; + /** + * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website + * content. + */ + zIndex?: number; +}; + +export type InlineContentScriptUiOptions = BaseContentScriptUiOptions & { + type: 'inline'; +}; + +export type ContentScriptUiOverlayAlignment = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right'; + +export type ContentScriptAppendMode = + | 'last' + | 'first' + | 'replace' + | 'before' + | 'after'; + +export type ContentScriptUiOptions = + | OverlayContentScriptUiOptions + | ModalContentScriptUiOptions + | InlineContentScriptUiOptions; diff --git a/src/client/index.ts b/src/client/index.ts index 606665e7..a37bc028 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,4 @@ export * from './defineContentScript'; export * from './defineBackground'; -export * from './mountContentScriptUi'; +export * from './createContentScriptUi'; export * from './utils/ContentScriptContext'; diff --git a/src/client/mountContentScriptUi.ts b/src/client/mountContentScriptUi.ts deleted file mode 100644 index ccc2fff8..00000000 --- a/src/client/mountContentScriptUi.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function mountContentScriptUi() { - throw Error('Not implemented: mountContentScriptUi'); -} diff --git a/src/client/utils/ContentScriptContext.ts b/src/client/utils/ContentScriptContext.ts index b51a930e..93e9095c 100644 --- a/src/client/utils/ContentScriptContext.ts +++ b/src/client/utils/ContentScriptContext.ts @@ -1,3 +1,4 @@ +import { ContentScriptDefinition } from '../../core/types'; import { browser } from '../browser'; import { logger } from './logger'; @@ -13,7 +14,10 @@ export class ContentScriptContext extends AbortController { #isTopFrame = window.self === window.top; - constructor(private readonly contentScriptName: string) { + constructor( + private readonly contentScriptName: string, + public readonly options?: Omit, + ) { super(); if (this.#isTopFrame) { @@ -157,10 +161,13 @@ export class ContentScriptContext extends AbortController { #stopOldScripts() { // Use postMessage so it get's sent to all the frames of the page. - window.postMessage({ - event: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE, - contentScriptName: this.contentScriptName, - }); + window.postMessage( + { + event: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE, + contentScriptName: this.contentScriptName, + }, + '*', + ); } #listenForNewerScripts() { diff --git a/src/client/virtual-modules/content-script-entrypoint.ts b/src/client/virtual-modules/content-script-entrypoint.ts index 320686a1..9e8c9cad 100644 --- a/src/client/virtual-modules/content-script-entrypoint.ts +++ b/src/client/virtual-modules/content-script-entrypoint.ts @@ -4,9 +4,10 @@ import { ContentScriptContext } from '../utils/ContentScriptContext'; (async () => { try { - const ctx = new ContentScriptContext(__ENTRYPOINT__); + const { main, ...options } = definition; + const ctx = new ContentScriptContext(__ENTRYPOINT__, options); - await definition.main(ctx); + await main(ctx); } catch (err) { logger.error('The content script crashed on startup!', err); } diff --git a/src/core/server.ts b/src/core/server.ts index 553899d5..859c2fca 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -7,7 +7,10 @@ import { import * as vite from 'vite'; import { Scripting } from 'webextension-polyfill'; import { getEntrypointBundlePath } from './utils/entrypoints'; -import { getContentScriptCssFiles } from './utils/manifest'; +import { + getContentScriptCssFiles, + getContentScriptsCssMap, +} from './utils/manifest'; import { buildInternal } from './build'; import { createExtensionRunner } from './runners'; @@ -99,7 +102,8 @@ export function reloadContentScripts( if (Array.isArray(entry) || entry.type !== 'content-script') return; const js = [getEntrypointBundlePath(entry, config.outDir, '.js')]; - const css = getContentScriptCssFiles([entry], server.currentOutput); + const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]); + const css = getContentScriptCssFiles([entry], cssMap); server.reloadContentScript({ allFrames: entry.options.allFrames, diff --git a/src/core/types/external.ts b/src/core/types/external.ts index d72e64ef..9bb48813 100644 --- a/src/core/types/external.ts +++ b/src/core/types/external.ts @@ -417,7 +417,7 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint { * * @default "manifest" */ - cssInjectionMode?: 'manifest' | 'manual'; + cssInjectionMode?: 'manifest' | 'manual' | 'ui'; /** * Main function executed when the content script is loaded. */ diff --git a/src/core/utils/manifest.ts b/src/core/utils/manifest.ts index 0fff3dd0..052a5a4d 100644 --- a/src/core/utils/manifest.ts +++ b/src/core/utils/manifest.ts @@ -20,6 +20,7 @@ import { getPackageJson } from './package'; import { normalizePath } from './paths'; import { writeFileIfDifferent } from './fs'; import { produce } from 'immer'; +import * as vite from 'vite'; /** * Writes the manifest to the output directory and the build output. @@ -56,22 +57,25 @@ export async function generateMainfest( ): Promise { const pkg = await getPackageJson(config); - const manifest: Manifest.WebExtensionManifest = Object.assign( - { - manifest_version: config.manifestVersion, - name: pkg?.name, - description: pkg?.description, - version: pkg?.version && simplifyVersion(pkg.version), - // Only add the version name to chromium and if the user hasn't specified a custom version. - version_name: - config.browser !== 'firefox' && !config.manifest.version - ? pkg?.version - : undefined, - short_name: pkg?.shortName, - icons: discoverIcons(buildOutput), - }, - config.manifest, - ); + const baseManifest: Manifest.WebExtensionManifest = { + manifest_version: config.manifestVersion, + name: pkg?.name, + description: pkg?.description, + version: pkg?.version && simplifyVersion(pkg.version), + // Only add the version name to chromium and if the user hasn't specified a custom version. + version_name: + config.browser !== 'firefox' && !config.manifest.version + ? pkg?.version + : undefined, + short_name: pkg?.shortName, + icons: discoverIcons(buildOutput), + }; + const userManifest = config.manifest; + + const manifest = vite.mergeConfig( + baseManifest, + userManifest, + ) as Manifest.WebExtensionManifest; addEntrypoints(manifest, entrypoints, buildOutput, config); @@ -284,6 +288,8 @@ function addEntrypoints( } if (contentScripts?.length) { + const cssMap = getContentScriptsCssMap(buildOutput, contentScripts); + // Don't add content scripts to the manifest in dev mode for MV3 - they're managed and reloaded // at runtime if (config.command === 'serve' && config.manifestVersion === 3) { @@ -309,7 +315,7 @@ function addEntrypoints( ...mapWxtOptionsToContentScript(scripts[0].options), // TOOD: Sorting css and js arrays here so we get consistent test results... but we // shouldn't have to. Where is the inconsistency coming from? - css: getContentScriptCssFiles(scripts, buildOutput)?.sort(), + css: getContentScriptCssFiles(scripts, cssMap)?.sort(), js: scripts .map((entry) => getEntrypointBundlePath(entry, config.outDir, '.js'), @@ -322,6 +328,16 @@ function addEntrypoints( manifest.content_scripts.push(...newContentScripts); } } + + const contentScriptCssResources = getContentScriptCssWebAccessibleResources( + config, + contentScripts, + cssMap, + ); + if (contentScriptCssResources.length > 0) { + manifest.web_accessible_resources ??= []; + manifest.web_accessible_resources.push(...contentScriptCssResources); + } } } @@ -409,25 +425,77 @@ function addDevModePermissions( */ export function getContentScriptCssFiles( contentScripts: ContentScriptEntrypoint[], - buildOutput: Omit, + contentScriptCssMap: Record, ): string[] | undefined { const css: string[] = []; - const allChunks = buildOutput.steps.flatMap((step) => step.chunks); - contentScripts.forEach((script) => { - if (script.options.cssInjectionMode === 'manual') return; + if ( + script.options.cssInjectionMode === 'manual' || + script.options.cssInjectionMode === 'ui' + ) + return; - const relatedCss = allChunks.find( - (chunk) => chunk.fileName === `content-scripts/${script.name}.css`, - ); - if (relatedCss) css.push(relatedCss.fileName); + const cssFile = contentScriptCssMap[script.name]; + if (cssFile == null) return; + + if (cssFile) css.push(cssFile); }); if (css.length > 0) return css; return undefined; } +/** + * Content scripts configured with `cssInjectionMode: "ui"` need to add their CSS files to web + * accessible resources so they can be fetched as text and added to shadow roots that the UI is + * added to. + */ +export function getContentScriptCssWebAccessibleResources( + config: InternalConfig, + contentScripts: ContentScriptEntrypoint[], + contentScriptCssMap: Record, +): any[] { + const resources: any[] = []; + + contentScripts.forEach((script) => { + if (script.options.cssInjectionMode !== 'ui') return; + + const cssFile = contentScriptCssMap[script.name]; + if (cssFile == null) return; + + if (config.manifestVersion === 2) { + resources.push(cssFile); + } else { + resources.push({ + resources: [cssFile], + matches: script.options.matches, + }); + } + }); + + return resources; +} + +/** + * Based on the build output, return a Record of each content script's name to it CSS file if the + * script includes one. + */ +export function getContentScriptsCssMap( + buildOutput: Omit, + scripts: ContentScriptEntrypoint[], +) { + const map: Record = {}; + const allChunks = buildOutput.steps.flatMap((step) => step.chunks); + scripts.forEach((script) => { + const relatedCss = allChunks.find( + (chunk) => chunk.fileName === `content-scripts/${script.name}.css`, + ); + if (relatedCss != null) map[script.name] = relatedCss.fileName; + }); + return map; +} + function addPermission( manifest: Manifest.WebExtensionManifest, permission: string, diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 00000000..f5a0d17a --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,2 @@ +// @ts-expect-error +globalThis.__ENTRYPOINT__ = 'test'; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index cc32a935..d55ee809 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -25,6 +25,7 @@ export default defineWorkspace([ dir: 'src', mockReset: true, restoreMocks: true, + setupFiles: 'vitest.setup.ts', }, plugins: [testSeed()], },