Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/content-script/menu-tools/index.mjs
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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)
}
Comment thread
PeterDaveHello marked this conversation as resolved.
},
},
}
113 changes: 113 additions & 0 deletions tests/unit/background/commands.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
156 changes: 156 additions & 0 deletions tests/unit/content-script/menu-tools.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
}
})