From 57eac8e8b91e842e1386275ce8db463c1e1094d9 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Mon, 27 Sep 2021 21:43:31 +0700 Subject: [PATCH] Implement "to content script" via background page (#29) --- index.ts | 67 ++++++++++++----- package.json | 3 +- test/demo-extension/background/api.ts | 6 ++ .../demo-extension/background/registration.ts | 9 +++ test/demo-extension/background/testingApi.ts | 33 +++++++++ test/demo-extension/contentscript/api.test.ts | 72 +++++++++---------- test/demo-extension/manifest.json | 4 ++ 7 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 test/demo-extension/background/testingApi.ts diff --git a/index.ts b/index.ts index 6421138..4a8f12b 100644 --- a/index.ts +++ b/index.ts @@ -53,8 +53,14 @@ type Method = (this: MessengerMeta, ...args: Arguments) => Promise; // TODO: It may include additional meta, like information about the original sender type Message = { - type: string; + type: keyof MessengerMethods; args: TArguments; + + /** If the message is being sent to an intermediary receiver, also set the target */ + target?: Target; + + /** If the message is being sent to an intermediary receiver, also set the options */ + options?: Target; }; type MessengerMessage = Message & { @@ -82,20 +88,14 @@ function isMessengerResponse(response: unknown): response is MessengerResponse { const handlers = new Map(); -async function handleMessage( +async function handleCall( message: Message, - sender: MessengerMeta + sender: MessengerMeta, + call: Promise | unknown ): Promise { - const handler = handlers.get(message.type); - if (!handler) { - throw new Error("No handler registered for " + message.type); - } - console.debug(`Messenger:`, message.type, message.args, "from", { sender }); // The handler could actually be a synchronous function - const response = await Promise.resolve( - handler.call(sender, ...message.args) - ).then( + const response = await Promise.resolve(call).then( (value) => ({ value }), (error: unknown) => ({ // Errors must be serialized because the stacktraces are currently lost on Chrome and @@ -108,6 +108,27 @@ async function handleMessage( return { ...response, __webext_messenger__ }; } +async function handleMessage( + message: Message, + sender: MessengerMeta +): Promise { + if (message.target) { + const publicMethod = getContentScriptMethod(message.type); + return handleCall( + message, + sender, + publicMethod(message.target, ...message.args) + ); + } + + const handler = handlers.get(message.type); + if (handler) { + return handleCall(message, sender, handler.apply(sender, message.args)); + } + + throw new Error("No handler registered for " + message.type); +} + // Do not turn this into an `async` function; Notifications must turn `void` function manageConnection( type: string, @@ -179,11 +200,16 @@ interface Options { isNotification?: boolean; } -function makeMessage(type: string, args: unknown[]): MessengerMessage { +function makeMessage( + type: keyof MessengerMethods, + args: unknown[], + target?: Target +): MessengerMessage { return { __webext_messenger__, type, args, + target, }; } @@ -210,13 +236,16 @@ function getContentScriptMethod< TPublicMethod extends PublicMethodWithTarget >(type: TType, options: Options = {}): TPublicMethod { const publicMethod = (target: Target, ...args: Parameters) => { - const sendMessage = async () => - browser.tabs.sendMessage( - target.tabId, - makeMessage(type, args), - // `frameId` must be specified. If missing, the message would be sent to every frame - { frameId: target.frameId ?? 0 } - ); + // eslint-disable-next-line no-negated-condition -- Looks better + const sendMessage = !browser.tabs + ? async () => browser.runtime.sendMessage(makeMessage(type, args, target)) + : async () => + browser.tabs.sendMessage( + target.tabId, + makeMessage(type, args), + // `frameId` must be specified. If missing, the message is sent to every frame + { frameId: target.frameId ?? 0 } + ); return manageConnection(type, options, sendMessage); }; diff --git a/package.json b/package.json index cdfb175..82f16a4 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,8 @@ "sourceDir": "test/dist", "run": { "startUrl": [ - "https://legiblenews.com/" + "https://legiblenews.com/", + "https://rawtext.club/" ] } } diff --git a/test/demo-extension/background/api.ts b/test/demo-extension/background/api.ts index d0c253e..3609dad 100644 --- a/test/demo-extension/background/api.ts +++ b/test/demo-extension/background/api.ts @@ -1,5 +1,11 @@ import { getMethod } from "../../../index"; +// Dog-fooding, needed to run the tests +export const openTab = getMethod("openTab"); +export const closeTab = getMethod("closeTab"); +export const getAllFrames = getMethod("getAllFrames"); +export const ensureScripts = getMethod("ensureScripts"); + export const sum = getMethod("sum"); export const throws = getMethod("throws"); export const sumIfMeta = getMethod("sumIfMeta"); diff --git a/test/demo-extension/background/registration.ts b/test/demo-extension/background/registration.ts index 104528d..f5e2a23 100644 --- a/test/demo-extension/background/registration.ts +++ b/test/demo-extension/background/registration.ts @@ -8,6 +8,7 @@ import { getExtensionId } from "./getExtensionId"; import { backgroundOnly } from "./backgroundOnly"; import { notRegistered } from "./notRegistered"; import { getSelf } from "./getSelf"; +import { openTab, getAllFrames, ensureScripts, closeTab } from "./testingApi"; declare global { interface MessengerMethods { @@ -18,6 +19,10 @@ declare global { getExtensionId: typeof getExtensionId; backgroundOnly: typeof backgroundOnly; getSelf: typeof getSelf; + openTab: typeof openTab; + getAllFrames: typeof getAllFrames; + ensureScripts: typeof ensureScripts; + closeTab: typeof closeTab; } } @@ -34,4 +39,8 @@ registerMethods({ sumIfMeta, throws, getSelf, + openTab, + getAllFrames, + ensureScripts, + closeTab, }); diff --git a/test/demo-extension/background/testingApi.ts b/test/demo-extension/background/testingApi.ts new file mode 100644 index 0000000..02733a7 --- /dev/null +++ b/test/demo-extension/background/testingApi.ts @@ -0,0 +1,33 @@ +export async function ensureScripts(tabId: number): Promise { + await browser.tabs.executeScript(tabId, { + // https://github.com/parcel-bundler/parcel/issues/5758 + file: + "/up_/up_/node_modules/webextension-polyfill/dist/browser-polyfill.js", + }); + await browser.tabs.executeScript(tabId, { + file: "contentscript/registration.js", + }); +} + +export async function getAllFrames( + tabId: number +): Promise<[parentFrame: number, iframe: number]> { + const [parentFrame, iframe] = await browser.webNavigation.getAllFrames({ + tabId, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return [parentFrame!.frameId, iframe!.frameId]; +} + +export async function openTab(url: string): Promise { + const tab = await browser.tabs.create({ + active: false, + url, + }); + return tab.id!; +} + +export async function closeTab(tabId: number): Promise { + await browser.tabs.remove(tabId); +} diff --git a/test/demo-extension/contentscript/api.test.ts b/test/demo-extension/contentscript/api.test.ts index f1ddad2..86e9762 100644 --- a/test/demo-extension/contentscript/api.test.ts +++ b/test/demo-extension/contentscript/api.test.ts @@ -1,5 +1,8 @@ import * as test from "fresh-tape"; +import { isBackgroundPage } from "webext-detect-page"; import { Target } from "../../../index"; +import * as backgroundContext from "../background/api"; +import * as localContext from "../background/testingApi"; import { getPageTitle, setPageTitle, @@ -13,6 +16,10 @@ import { getPageTitleNotification, } from "./api"; +const { openTab, getAllFrames, ensureScripts, closeTab } = isBackgroundPage() + ? localContext + : backgroundContext; + async function delay(timeout: number): Promise { await new Promise((resolve) => { setTimeout(resolve, timeout); @@ -99,8 +106,13 @@ function runOnTarget(target: Target, expectedTitle: string) { const self = await getSelf(target); t.true(self instanceof Object); t.equals(self.id, chrome.runtime.id); - // Chrome (the types are just for Firefox) || Firefox - t.true((self as any).origin === "null" || self.url === location.href); + + // TODO: `as any` because `self` is typed for Firefox only + // TODO: self.url always points to the background page, but it should include the current tab when forwarded https://github.com/pixiebrix/webext-messenger/issues/32 + t.true( + (self as any).origin === "null" || // Chrome + self.url?.endsWith("/_generated_background_page.html") // Firefox + ); }); test(expectedTitle + ": notification should return undefined", async (t) => { @@ -119,25 +131,19 @@ function runOnTarget(target: Target, expectedTitle: string) { } async function init() { - const { id } = await browser.tabs.create({ - url: "https://iframe-test-page.vercel.app/", - }); + const tabId = await openTab("https://iframe-test-page.vercel.app/"); await delay(1000); // Let frames load so we can query them for the tests - const [parentFrame, iframe] = await browser.webNavigation.getAllFrames({ - tabId: id!, - }); + const [parentFrame, iframe] = await getAllFrames(tabId); // All `test` calls must be done synchronously, or else the runner assumes they're done - runOnTarget({ tabId: id!, frameId: parentFrame!.frameId }, "Parent"); - runOnTarget({ tabId: id!, frameId: iframe!.frameId }, "Child"); + runOnTarget({ tabId, frameId: parentFrame }, "Parent"); + runOnTarget({ tabId, frameId: iframe }, "Child"); test("should throw the right error when `registerMethod` was never called", async (t) => { - const tab = await browser.tabs.create({ - url: "https://text.npr.org/", - }); + const tabId = await openTab("https://text.npr.org/"); try { - await getPageTitle({ tabId: tab.id! }); + await getPageTitle({ tabId }); t.fail("getPageTitle() should have thrown but did not"); } catch (error: unknown) { if (!(error instanceof Error)) { @@ -147,49 +153,37 @@ async function init() { t.equal(error.message, "No handlers registered in receiving end"); - await browser.tabs.remove(tab.id!); + await closeTab(tabId); } }); test("should be able to close the tab from the content script", async (t) => { - await closeSelf({ tabId: id!, frameId: parentFrame!.frameId }); + await closeSelf({ tabId, frameId: parentFrame }); try { // Since the tab was closed, this is expected to throw - t.notOk(await browser.tabs.get(id!), "The tab should not be open"); + t.notOk(await browser.tabs.get(tabId), "The tab should not be open"); } catch { t.pass("The tab was closed"); } }); test("retries until target is ready", async (t) => { - const tab = await browser.tabs.create({ - url: "http://lite.cnn.com/", - }); - const tabId = tab.id!; + const tabId = await openTab("http://lite.cnn.com/"); const request = getPageTitle({ tabId }); await delay(1000); // Simulate a slow-loading tab - await browser.tabs.executeScript(tabId, { - // https://github.com/parcel-bundler/parcel/issues/5758 - file: - "/up_/up_/node_modules/webextension-polyfill/dist/browser-polyfill.js", - }); - await browser.tabs.executeScript(tabId, { - file: "contentscript/registration.js", - }); + await ensureScripts(tabId); t.equal(await request, "CNN - Breaking News, Latest News and Videos"); - await browser.tabs.remove(tabId); + await closeTab(tabId); }); test("retries until it times out", async (t) => { - const tab = await browser.tabs.create({ - url: "http://lite.cnn.com/", - }); + const tabId = await openTab("http://lite.cnn.com/"); const startTime = Date.now(); try { - await getPageTitle({ tabId: tab.id! }); + await getPageTitle({ tabId }); t.fail("getPageTitle() should have thrown but did not"); } catch (error: unknown) { if (!(error instanceof Error)) { @@ -208,7 +202,7 @@ async function init() { ); } - await browser.tabs.remove(tab.id!); + await closeTab(tabId); }); test("notifications on non-existing targets", async (t) => { @@ -223,11 +217,9 @@ async function init() { }); test("notifications when `registerMethod` was never called", async () => { - const tab = await browser.tabs.create({ - url: "http://lite.cnn.com/", - }); - getPageTitleNotification({ tabId: tab.id! }); - await browser.tabs.remove(tab.id!); + const tabId = await openTab("http://lite.cnn.com/"); + getPageTitleNotification({ tabId }); + await closeTab(tabId); }); } diff --git a/test/demo-extension/manifest.json b/test/demo-extension/manifest.json index db641c7..b1ddee5 100644 --- a/test/demo-extension/manifest.json +++ b/test/demo-extension/manifest.json @@ -33,6 +33,10 @@ "contentscript/registration.ts" ] }, + { + "matches": ["https://rawtext.club/*"], + "js": ["~node_modules/webextension-polyfill", "contentscript/api.test.ts"] + }, { "all_frames": true, "matches": ["https://text.npr.org/*"],