diff --git a/apps/content/src/browser/browser.js b/apps/content/src/browser/browser.js index 7f9a0fe..dc29d1f 100644 --- a/apps/content/src/browser/browser.js +++ b/apps/content/src/browser/browser.js @@ -10,7 +10,7 @@ import { registerEventBus } from './windowApi/eventBus.js' // Handle window arguments let rawArgs = window.arguments && window.arguments[0] -/** @type {Record} */ +/** @type {Record} */ let args = {} if (rawArgs && rawArgs instanceof Ci.nsISupports) { @@ -19,8 +19,8 @@ if (rawArgs && rawArgs instanceof Ci.nsISupports) { args = rawArgs } -const initialUrls = args.initialUrl - ? [args.initialUrl] +const initialUrls = args.initialUrls + ? args.initialUrls : ['https://google.com/', 'https://svelte.dev/'] WindowTabs.initialize(initialUrls) @@ -30,3 +30,6 @@ registerEventBus() new BrowserWindow({ target: document.body }) browserImports.WindowTracker.registerWindow(window) +window.addEventListener('unload', () => + browserImports.WindowTracker.removeWindow(window), +) diff --git a/apps/content/src/browser/windowApi/WebsiteView.js b/apps/content/src/browser/windowApi/WebsiteView.js index 8ca47aa..858d07f 100644 --- a/apps/content/src/browser/windowApi/WebsiteView.js +++ b/apps/content/src/browser/windowApi/WebsiteView.js @@ -31,6 +31,8 @@ export function create(uri) { const view = { windowBrowserId: nextWindowBrowserId++, browser: createBrowser(uri), + uri, + websiteState: 'loading', /** @type {import('mitt').Emitter} */ events: mitt(), @@ -49,6 +51,12 @@ export function create(uri) { registerViewThemeListener(view) }) + view.events.on('locationChange', (e) => (view.uri = e.aLocation)) + view.events.on( + 'loadingChange', + (e) => (view.websiteState = e ? 'loading' : 'complete'), + ) + eventBus.on('iconUpdate', ({ browserId, iconUrl }) => { if (view.browser.browserId === browserId) { view.iconUrl = iconUrl @@ -293,6 +301,7 @@ class TabProgressListener { * @returns {void} */ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + if (!aWebProgress || !aWebProgress.isTopLevel) return this.view.events.emit('locationChange', { aWebProgress, aRequest, diff --git a/apps/extensions/lib/ext-browser.json b/apps/extensions/lib/ext-browser.json index b86845e..e9ebae6 100644 --- a/apps/extensions/lib/ext-browser.json +++ b/apps/extensions/lib/ext-browser.json @@ -9,7 +9,7 @@ "tabs": { "schema": "chrome://bextensions/content/schemas/tabs.json", "url": "chrome://bextensions/content/parent/ext-tabs.js", - "scopes": "addon_parent", + "scopes": ["addon_parent"], "paths": [["tabs"]] } } diff --git a/apps/extensions/lib/parent/ext-tabs.js b/apps/extensions/lib/parent/ext-tabs.js index 87aed5c..fe92a94 100644 --- a/apps/extensions/lib/parent/ext-tabs.js +++ b/apps/extensions/lib/parent/ext-tabs.js @@ -13,6 +13,7 @@ */ function query(queryInfo) { const windows = [...lazy.WindowTracker.registeredWindows.entries()] + console.log(queryInfo) const urlMatchSet = (queryInfo.url && @@ -25,17 +26,10 @@ function query(queryInfo) { const tabs = window.windowTabs() const activeTab = window.activeTab() - if ( - typeof queryInfo.windowId !== 'undefined' && - queryInfo.windowId != windowId - ) { - return [] - } - return tabs .filter((tab) => { const active = - typeof queryInfo.active !== 'undefined' + queryInfo.active !== null ? queryInfo.active ? tab === activeTab : tab !== activeTab @@ -46,9 +40,11 @@ function query(queryInfo) { const url = urlMatchSet === null ? true - : urlMatchSet.matches(tab.view.browser.browsingContext?.currentURI) + : urlMatchSet.matches(tab.view.uri.asciiSpec) + const window = + queryInfo.windowId === null ? true : queryInfo.windowId === windowId - return active && title && url + return active && title && url && window }) .map( /** @returns {[import("@browser/tabs").WindowTab, Window]} */ (tab) => [ @@ -75,9 +71,7 @@ const serialize = active: window.activeTab() === tab, highlighted: false, // TODO title: hasTabPermission && tab.view.title, - url: - hasTabPermission && - tab.view.browser.browsingContext?.currentURI.asciiSpec, + url: hasTabPermission && tab.view.uri.asciiSpec, windowId: window.windowId, } } diff --git a/apps/extensions/lib/schemaTypes/tabs.d.ts b/apps/extensions/lib/schemaTypes/tabs.d.ts index fef36b0..902d978 100644 --- a/apps/extensions/lib/schemaTypes/tabs.d.ts +++ b/apps/extensions/lib/schemaTypes/tabs.d.ts @@ -4,6 +4,11 @@ // // DO NOT MODIFY MANUALLY +declare module tabs__manifest { + type ApiGetterReturn = { + manifest: {} + } +} declare module tabs__tabs { type Tab = { id?: number diff --git a/apps/extensions/lib/schemas/tabs.json b/apps/extensions/lib/schemas/tabs.json index 5a15007..284735a 100644 --- a/apps/extensions/lib/schemas/tabs.json +++ b/apps/extensions/lib/schemas/tabs.json @@ -3,6 +3,21 @@ // found in the LICENSE file. [ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "id": "ExtraPerms1", + "choices": [ + { + "type": "string", + "enum": ["tabs", "tabHide"] + } + ] + } + ] + }, { "namespace": "tabs", "description": "Provides access to information about currently open tabs", diff --git a/apps/extensions/scripts/buildTypes.js b/apps/extensions/scripts/buildTypes.js index ed49554..b6e1725 100644 --- a/apps/extensions/scripts/buildTypes.js +++ b/apps/extensions/scripts/buildTypes.js @@ -1,7 +1,6 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - // @ts-check import * as fs from 'node:fs' import * as path from 'node:path' @@ -138,14 +137,18 @@ for (const file of fs.readdirSync(schemaFolder)) { * @returns {import('typescript').TypeAliasDeclaration[]} */ function generateTypes(types) { - return types.map((type) => - factory.createTypeAliasDeclaration( - undefined, - type.id, - undefined, - generateTypeNode(type), - ), - ) + return types + .map((type) => { + if (type.$extend) return null + + return factory.createTypeAliasDeclaration( + undefined, + type.id, + undefined, + generateTypeNode(type), + ) + }) + .filter(Boolean) } /** diff --git a/apps/modules/lib/BrowserWindowTracker.sys.mjs b/apps/modules/lib/BrowserWindowTracker.sys.mjs index 709087f..17805c3 100644 --- a/apps/modules/lib/BrowserWindowTracker.sys.mjs +++ b/apps/modules/lib/BrowserWindowTracker.sys.mjs @@ -7,7 +7,10 @@ import mitt from 'resource://app/modules/mitt.sys.mjs' /** @type {import('resource://app/modules/BrowserWindowTracker.sys.mjs')['WindowTracker']} */ export const WindowTracker = { - nextWindowId: 0, + /** + * 1 indexed to stop having a falsey window id + */ + nextWindowId: 1, events: mitt(), diff --git a/apps/modules/lib/ExtensionTestUtils.sys.mjs b/apps/modules/lib/ExtensionTestUtils.sys.mjs index 14dc282..5746e2e 100644 --- a/apps/modules/lib/ExtensionTestUtils.sys.mjs +++ b/apps/modules/lib/ExtensionTestUtils.sys.mjs @@ -1,7 +1,6 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - // @ts-check import { lazyESModuleGetters } from 'resource://app/modules/TypedImportUtils.sys.mjs' @@ -178,10 +177,8 @@ const objectMap = (obj, fn) => */ class ExtensionTestUtilsImpl { /** - * @template {import('resource://app/modules/zora.sys.mjs').IAssert} A - * * @param {Partial} definition - * @param {import('resource://app/modules/ExtensionTestUtils.sys.mjs').AddonMiddleware} assert + * @param {import('resource://app/modules/TestManager.sys.mjs').IDefaultAssert} assert * * @returns {import('resource://app/modules/ExtensionTestUtils.sys.mjs').ExtensionWrapper} */ @@ -193,8 +190,13 @@ class ExtensionTestUtilsImpl { definition.background && serializeScript(definition.background), }) + let testCount = 0 + /** @type {number | null} */ + let expectedTestCount = null + function handleTestResults(kind, pass, msg, ...args) { if (kind == 'test-eq') { + testCount += 1 let [expected, actual] = args assert.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`) } else if (kind == 'test-log') { @@ -232,25 +234,42 @@ class ExtensionTestUtilsImpl { /* Ignore */ } await extension.startup() - return await startupPromise + await startupPromise } catch (e) { assert.fail(`Errored: ${e}`) } + + return this }, async unload() { await extension.shutdown() - return await extension._uninstallPromise + await extension._uninstallPromise + + if (expectedTestCount && testCount !== expectedTestCount) { + assert.fail( + `Expected ${expectedTestCount} to execute. ${testCount} extecuted instead`, + ) + } }, + /** + * @param {number} count + */ + testCount(count) { + expectedTestCount = count + return this + }, sendMsg(msg) { extension.testMessage(msg) + return this }, async awaitMsg(msg) { + const self = this return new Promise((res) => { const callback = (_, event) => { if (event == msg) { extension.off('test-message', callback) - res(void 0) + res(self) } } diff --git a/apps/modules/lib/TestManager.sys.mjs b/apps/modules/lib/TestManager.sys.mjs index 81e4d1d..0807f93 100644 --- a/apps/modules/lib/TestManager.sys.mjs +++ b/apps/modules/lib/TestManager.sys.mjs @@ -46,11 +46,11 @@ class TestManagerSingleton { } /** - * @param {string} initialUrl + * @param {string[]} initialUrls * @param {(win: Window) => Promise} using */ - async withBrowser(initialUrl, using) { - const args = { initialUrl } + async withBrowser(initialUrls, using) { + const args = { initialUrls } /** @type {Window} */ // @ts-expect-error Incorrect type gen diff --git a/apps/tests/integrations/_index.sys.mjs b/apps/tests/integrations/_index.sys.mjs index b7f8260..c1a4e99 100644 --- a/apps/tests/integrations/_index.sys.mjs +++ b/apps/tests/integrations/_index.sys.mjs @@ -2,3 +2,4 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import './extensions/pageAction.mjs' +import './extensions/tabs.mjs' diff --git a/apps/tests/integrations/extensions/pageAction.mjs b/apps/tests/integrations/extensions/pageAction.mjs index 6c62ca5..76ecdbc 100644 --- a/apps/tests/integrations/extensions/pageAction.mjs +++ b/apps/tests/integrations/extensions/pageAction.mjs @@ -16,8 +16,8 @@ async function spinLock(predicate) { } } -await TestManager.withBrowser('http://example.com/', async (window) => { - await TestManager.test('Extension Test', async (test) => { +await TestManager.withBrowser(['http://example.com/'], async (window) => { + await TestManager.test('pageAction - Icon & Panel', async (test) => { const extension = ExtensionTestUtils.loadExtension( { manifest: { diff --git a/apps/tests/integrations/extensions/tabs.mjs b/apps/tests/integrations/extensions/tabs.mjs new file mode 100644 index 0000000..20838a1 --- /dev/null +++ b/apps/tests/integrations/extensions/tabs.mjs @@ -0,0 +1,97 @@ +// @ts-check +/// +/// +import { ExtensionTestUtils } from 'resource://app/modules/ExtensionTestUtils.sys.mjs' +import { TestManager } from 'resource://app/modules/TestManager.sys.mjs' + +/** + * @param {() => boolean} predicate + * @returns {Promise} + */ +async function spinLock(predicate) { + while (!predicate()) { + await new Promise((res) => setTimeout(res, 100)) + } +} + +await TestManager.withBrowser( + ['https://example.com/', 'https://google.com'], + async (window) => { + await spinLock(() => + window + ?.windowTabs() + .map( + (tab) => + tab.view.browser?.mInitialized && + tab.view.websiteState === 'complete', + ) + .reduce((p, c) => p && c, true), + ) + + await TestManager.test('tabs - Basic Query', async (test) => { + const extension = ExtensionTestUtils.loadExtension( + { + manifest: { + permissions: ['tabs'], + }, + async background() { + /** @type {import('resource://app/modules/ExtensionTestUtils.sys.mjs').TestBrowser} */ + const b = this.browser + + b.test.onMessage.addListener(async (msg) => { + const windowId = Number(msg) + const urlResults = await b.tabs.query({ + url: 'https://example.com/', + }) + b.test.assertEq( + 1, + urlResults.length, + 'There must only be one tab matching https://example.com', + ) + b.test.assertEq( + 'https://example.com/', + urlResults[0].url, + 'The url must match the original filter', + ) + + const windowResults = await b.tabs.query({ + windowId, + }) + console.log(JSON.stringify(windowResults)) + b.test.assertEq( + 2, + windowResults.length, + 'Window should have 2 tabs', + ) + b.test.assertEq( + ['https://example.com/', 'https://www.google.com/'].join(','), + [windowResults[0].url, windowResults[1].url].join(','), + 'Test tab urls', + ) + b.test.assertEq( + [true, false].join(','), + [windowResults[0].active, windowResults[1].active].join(','), + 'Ensure that active tab is the first one', + ) + b.test.assertEq( + ['Example Domain', 'Google'].join(','), + [windowResults[0].title, windowResults[1].title].join(','), + 'Titles should be roughly correct', + ) + + b.test.sendMessage('done') + }) + }, + }, + test, + ) + + await extension + .testCount(6) + .startup() + .then((e) => e.sendMsg(window.windowId.toString())) + .then((e) => e.awaitMsg('done')) + .then((e) => e.unload()) + }) + }, +) diff --git a/apps/tests/package.json b/apps/tests/package.json index 9110712..4c57fae 100644 --- a/apps/tests/package.json +++ b/apps/tests/package.json @@ -12,5 +12,8 @@ }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "devDependencies": { + "@types/firefox-webext-browser": "^120.0.0" + } } diff --git a/libs/link/types/modules/ExtensionTestUtils.d.ts b/libs/link/types/modules/ExtensionTestUtils.d.ts index a4e7cdc..2630dce 100644 --- a/libs/link/types/modules/ExtensionTestUtils.d.ts +++ b/libs/link/types/modules/ExtensionTestUtils.d.ts @@ -8,6 +8,38 @@ declare module 'resource://app/modules/ExtensionTestUtils.sys.mjs' { import type { Extension } from 'resource://gre/modules/Extension.sys.mjs' export type WebExtensionManifest = browser._manifest.WebExtensionManifest + export type TestBrowser = typeof browser & { + test: { + withHandlingUserInput: (callback: () => unknown) => unknown + notifyFail: (message: string) => unknown + notifyPass: (message: string) => unknown + log: (message: string) => unknown + sendMessage: (arg1?, arg2?) => unknown + fail: (message) => unknown + succeed: (message) => unknown + assertTrue: (test, message: string) => unknown + assertFalse: (test, message: string) => unknown + assertBool: ( + test: string | boolean, + expected: boolean, + message: string, + ) => unknown + assertDeepEq: (expected, actual, message: string) => unknown + assertEq: (expected, actual, message: string) => unknown + assertNoLastError: () => unknown + assertLastError: (expectedError: string) => unknown + assertRejects: ( + promise: Promise, + expectedError: ExpectedError, + message: string, + ) => unknown + assertThrows: ( + func: () => unknown, + expectedError: ExpectedError, + message: string, + ) => unknown + } + } /* eslint @typescript-eslint/ban-types: 0 */ export type ExtSerializableScript = string | Function | Array @@ -19,11 +51,15 @@ declare module 'resource://app/modules/ExtensionTestUtils.sys.mjs' { export type ExtensionWrapper = { extension: Extension - startup(): Promise<[string, string]> - unload(): Promise + startup(): Promise + unload(): Promise - sendMsg(msg: string): void - awaitMsg(msg: string): Promise + /** + * Specifies the number of tests that that this extension should execute + */ + testCount(count: number): ExtensionWrapper + sendMsg(msg: string): ExtensionWrapper + awaitMsg(msg: string): Promise } /** diff --git a/libs/link/types/modules/TestManager.d.ts b/libs/link/types/modules/TestManager.d.ts index 947691f..909afda 100644 --- a/libs/link/types/modules/TestManager.d.ts +++ b/libs/link/types/modules/TestManager.d.ts @@ -23,7 +23,7 @@ declare module 'resource://app/modules/TestManager.sys.mjs' { assertFn: (assert: IDefaultAssert) => Promise | void, ): Promise withBrowser( - defaultUrl: string, + defaultUrls: string[], using: (win: Window) => Promise, ): Promise diff --git a/libs/link/types/windowApi/WebsiteView.d.ts b/libs/link/types/windowApi/WebsiteView.d.ts index 1eb2c6c..af05297 100644 --- a/libs/link/types/windowApi/WebsiteView.d.ts +++ b/libs/link/types/windowApi/WebsiteView.d.ts @@ -29,6 +29,8 @@ declare type WebsiteViewEvents = { securityChange: number } +declare type WebsiteState = 'loading' | 'complete' + declare type WebsiteView = { windowBrowserId: number theme?: OklchTheme @@ -36,7 +38,9 @@ declare type WebsiteView = { title?: string browser: XULBrowserElement + uri: nsIURIType browserId?: number + websiteState: WebsiteState events: import('mitt').Emitter diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc0e415..b9d21c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,10 @@ importers: '@browser/link': specifier: workspace:* version: link:../../libs/link + devDependencies: + '@types/firefox-webext-browser': + specifier: ^120.0.0 + version: 120.0.0 libs/link: dependencies: @@ -704,7 +708,6 @@ packages: /@types/firefox-webext-browser@120.0.0: resolution: {integrity: sha512-L+tDlwNeq0kQGfAYc2sNfKhRWJz9CNRvlbq9HnLibKUiJ3VTThG8sj7xrJF4CtKpEA9eBAr91Z2nnKIAy+xUJg==} - dev: false /@types/html-minifier-terser@6.1.0: resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}