From 6a3499f7c5dd72d17652126b3b4645641c1b1d7a Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Sun, 19 Jul 2026 06:27:33 +0800 Subject: [PATCH] Handle close-all-chats errors Wait for the active-tab query and close message so callers can synchronize with completion. Treat missing tabs as a no-op and log browser API failures within the action because not every caller observes its returned promise. Add focused command, menu-tool, and side-panel regression coverage. --- src/content-script/menu-tools/index.mjs | 17 +- tests/unit/background/commands.test.mjs | 113 +++++++++++++ tests/unit/content-script/menu-tools.test.mjs | 156 ++++++++++++++++++ 3 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 tests/unit/background/commands.test.mjs create mode 100644 tests/unit/content-script/menu-tools.test.mjs diff --git a/src/content-script/menu-tools/index.mjs b/src/content-script/menu-tools/index.mjs index f777b5251..e37ff4c0d 100644 --- a/src/content-script/menu-tools/index.mjs +++ b/src/content-script/menu-tools/index.mjs @@ -1,7 +1,7 @@ -import { getCoreContentText } from '../../utils/get-core-content-text' +import { getCoreContentText } from '../../utils/get-core-content-text.mjs' import Browser from 'webextension-polyfill' import { getUserConfig } from '../../config/index.mjs' -import { openUrl } from '../../utils/open-url' +import { openUrl } from '../../utils/open-url.mjs' export const config = { newChat: { @@ -88,12 +88,19 @@ export const config = { label: 'Close All Chats In This Page', action: async (fromBackground) => { console.debug('action is from background', fromBackground) - Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { - Browser.tabs.sendMessage(tabs[0].id, { + + try { + const tabs = await Browser.tabs.query({ active: true, currentWindow: true }) + const currentTab = tabs && tabs[0] + if (currentTab?.id == null) return + + await Browser.tabs.sendMessage(currentTab.id, { type: 'CLOSE_CHATS', data: {}, }) - }) + } catch (error) { + console.error('failed to close all chats', error) + } }, }, } diff --git a/tests/unit/background/commands.test.mjs b/tests/unit/background/commands.test.mjs new file mode 100644 index 000000000..70ec9ec50 --- /dev/null +++ b/tests/unit/background/commands.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { setImmediate } from 'node:timers' +import { beforeEach, test } from 'node:test' +import Browser from 'webextension-polyfill' +import { registerCommands } from '../../../src/background/commands.mjs' +import { config as menuConfig } from '../../../src/content-script/menu-tools/index.mjs' + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)) + +function replaceBrowserMethod(t, target, name, replacement) { + const original = target[name] + Object.defineProperty(target, name, { + value: replacement, + writable: true, + configurable: true, + }) + t.after(() => { + Object.defineProperty(target, name, { + value: original, + writable: true, + configurable: true, + }) + }) +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.resetEvents() + registerCommands() +}) + +test('invokes command actions synchronously', (t) => { + let called = false + const originalAction = menuConfig.openSidePanel.action + menuConfig.openSidePanel.action = () => { + called = true + return Promise.resolve() + } + t.after(() => { + menuConfig.openSidePanel.action = originalAction + }) + + globalThis.chrome.commands.onCommand._trigger('openSidePanel', { id: 7, windowId: 9 }) + + assert.equal(called, true) +}) + +test('dispatches the closeAllChats command to its action', (t) => { + const tab = { id: 7, windowId: 9 } + const originalAction = menuConfig.closeAllChats.action + const action = t.mock.fn(() => Promise.resolve()) + menuConfig.closeAllChats.action = action + t.after(() => { + menuConfig.closeAllChats.action = originalAction + }) + + globalThis.chrome.commands.onCommand._trigger('closeAllChats', tab) + + assert.deepEqual(action.mock.calls[0].arguments, [true, tab]) +}) + +test('handles active-tab query failures for prompt commands', async (t) => { + const queryError = new Error('query failed') + replaceBrowserMethod(t, Browser.tabs, 'query', async () => { + throw queryError + }) + const consoleError = t.mock.method(console, 'error', () => {}) + + globalThis.chrome.commands.onCommand._trigger('newChat', { id: 7 }) + await flushPromises() + + assert.deepEqual(consoleError.mock.calls[0].arguments, [ + 'failed to query active tab for command "newChat"', + queryError, + ]) +}) + +test('does nothing when a prompt command has no active tab', async (t) => { + replaceBrowserMethod(t, Browser.tabs, 'query', async () => []) + const sendMessage = t.mock.fn() + replaceBrowserMethod(t, Browser.tabs, 'sendMessage', sendMessage) + + globalThis.chrome.commands.onCommand._trigger('newChat', { id: 7 }) + await flushPromises() + + assert.equal(sendMessage.mock.callCount(), 0) +}) + +test('handles content-script message failures for prompt commands', async (t) => { + const messageError = new Error('send failed') + replaceBrowserMethod(t, Browser.tabs, 'query', async () => [{ id: 7 }]) + replaceBrowserMethod(t, Browser.tabs, 'sendMessage', async () => { + throw messageError + }) + const consoleError = t.mock.method(console, 'error', () => {}) + + globalThis.chrome.commands.onCommand._trigger('newChat', { id: 7 }) + await flushPromises() + + assert.deepEqual(consoleError.mock.calls[0].arguments, [ + 'failed to send CREATE_CHAT message for command "newChat"', + messageError, + ]) +}) + +test('ignores unknown commands', async (t) => { + const query = t.mock.fn() + replaceBrowserMethod(t, Browser.tabs, 'query', query) + + globalThis.chrome.commands.onCommand._trigger('unknownCommand', { id: 7 }) + await flushPromises() + + assert.equal(query.mock.callCount(), 0) +}) diff --git a/tests/unit/content-script/menu-tools.test.mjs b/tests/unit/content-script/menu-tools.test.mjs new file mode 100644 index 000000000..22664a429 --- /dev/null +++ b/tests/unit/content-script/menu-tools.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { setImmediate } from 'node:timers' +import { describe, test } from 'node:test' +import Browser from 'webextension-polyfill' +import { config } from '../../../src/content-script/menu-tools/index.mjs' + +function replaceBrowserMethod(t, target, name, replacement) { + const original = target[name] + Object.defineProperty(target, name, { + value: replacement, + writable: true, + configurable: true, + }) + t.after(() => { + Object.defineProperty(target, name, { + value: original, + writable: true, + configurable: true, + }) + }) +} + +describe('closeAllChats', () => { + test('waits for the close message before resolving', async (t) => { + t.mock.method(console, 'debug', () => {}) + + let resolveMessage + const messagePromise = new Promise((resolve) => { + resolveMessage = resolve + }) + replaceBrowserMethod(t, Browser.tabs, 'query', async () => [{ id: 17 }]) + const sendMessage = t.mock.fn(() => messagePromise) + replaceBrowserMethod(t, Browser.tabs, 'sendMessage', sendMessage) + + let settled = false + const actionPromise = config.closeAllChats.action(true).then(() => { + settled = true + }) + await new Promise((resolve) => setImmediate(resolve)) + + assert.equal(settled, false) + assert.deepEqual(sendMessage.mock.calls[0].arguments, [ + 17, + { + type: 'CLOSE_CHATS', + data: {}, + }, + ]) + + resolveMessage() + await actionPromise + assert.equal(settled, true) + }) + + for (const [name, tabs] of [ + ['no active tab', []], + ['an active tab without an id', [{}]], + ]) { + test(`does nothing when there is ${name}`, async (t) => { + t.mock.method(console, 'debug', () => {}) + replaceBrowserMethod(t, Browser.tabs, 'query', async () => tabs) + const sendMessage = t.mock.fn() + replaceBrowserMethod(t, Browser.tabs, 'sendMessage', sendMessage) + + await config.closeAllChats.action(true) + + assert.equal(sendMessage.mock.callCount(), 0) + }) + } + + test('handles active-tab query failures', async (t) => { + t.mock.method(console, 'debug', () => {}) + const queryError = new Error('query failed') + replaceBrowserMethod(t, Browser.tabs, 'query', async () => { + throw queryError + }) + const consoleError = t.mock.method(console, 'error', () => {}) + + await config.closeAllChats.action(true) + + assert.deepEqual(consoleError.mock.calls[0].arguments, [ + 'failed to close all chats', + queryError, + ]) + }) + + test('handles close-message failures', async (t) => { + t.mock.method(console, 'debug', () => {}) + const messageError = new Error('send failed') + replaceBrowserMethod(t, Browser.tabs, 'query', async () => [{ id: 23 }]) + replaceBrowserMethod(t, Browser.tabs, 'sendMessage', async () => { + throw messageError + }) + const consoleError = t.mock.method(console, 'error', () => {}) + + await config.closeAllChats.action(true) + + assert.deepEqual(consoleError.mock.calls[0].arguments, [ + 'failed to close all chats', + messageError, + ]) + }) +}) + +describe('openSidePanel', () => { + test('opens the side panel synchronously with the active tab identifiers', async (t) => { + const originalSidePanel = globalThis.chrome.sidePanel + let called = false + const open = t.mock.fn(() => { + called = true + return Promise.resolve() + }) + globalThis.chrome.sidePanel = { open } + t.after(() => { + globalThis.chrome.sidePanel = originalSidePanel + }) + + const result = config.openSidePanel.action(true, { id: 7, windowId: 9 }) + + assert.equal(called, true) + assert.deepEqual(open.mock.calls[0].arguments, [{ windowId: 9, tabId: 7 }]) + await result + }) + + test('rejects when the side-panel API is unavailable', async (t) => { + const originalSidePanel = globalThis.chrome.sidePanel + globalThis.chrome.sidePanel = undefined + t.after(() => { + globalThis.chrome.sidePanel = originalSidePanel + }) + + await assert.rejects(config.openSidePanel.action(true, { id: 7, windowId: 9 }), { + message: 'chrome.sidePanel API is not available', + }) + }) + + for (const [name, tab] of [ + ['tab', undefined], + ['tab id', { windowId: 9 }], + ['window id', { id: 7 }], + ]) { + test(`rejects when the ${name} is missing`, async (t) => { + const originalSidePanel = globalThis.chrome.sidePanel + const open = t.mock.fn(() => Promise.resolve()) + globalThis.chrome.sidePanel = { open } + t.after(() => { + globalThis.chrome.sidePanel = originalSidePanel + }) + + await assert.rejects(config.openSidePanel.action(true, tab), { + message: 'chrome.sidePanel.open requires a tab with windowId and id', + }) + assert.equal(open.mock.callCount(), 0) + }) + } +})