diff --git a/tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs b/tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs new file mode 100644 index 000000000..dc3648445 --- /dev/null +++ b/tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs @@ -0,0 +1,103 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const floatingToolbarStubs = new Map([ + ['../ConversationCard', 'test:subscription-conversation-card'], + ['../../content-script/selection-tools', 'test:subscription-selection-tools'], + ['../../utils', 'test:subscription-utils'], + ['react-draggable', 'test:subscription-draggable'], + ['react-i18next', 'test:subscription-i18n'], +]) + +const useConfigStubs = new Map([ + ['../config/index.mjs', 'test:subscription-config'], + ['webextension-polyfill', 'test:subscription-browser'], +]) + +const sources = { + 'test:subscription-conversation-card': ` + export default function ConversationCard(props) { + globalThis.__FLOATING_SUBSCRIPTION_TEST__.onCloseBySession.set(props.session.id, props.onClose) + return null + } + `, + 'test:subscription-selection-tools': 'export const config = {}', + 'test:subscription-utils': ` + export const getClientPosition = () => ({ x: 0, y: 0 }) + export const isMobile = () => true + export const setElementPositionInViewport = (_container, x, y) => ({ x, y }) + `, + 'test:subscription-draggable': ` + export default function Draggable(props) { + return props.children + } + `, + 'test:subscription-i18n': 'export const useTranslation = () => ({ t: (value) => value })', + 'test:subscription-config': ` + export const defaultConfig = { + alwaysPinWindow: false, + themeMode: 'light', + activeSelectionTools: [], + customSelectionTools: [], + } + export const getUserConfig = async () => defaultConfig + `, + 'test:subscription-browser': ` + const state = globalThis.__FLOATING_SUBSCRIPTION_TEST__ + export default { + storage: { + local: { + onChanged: { + addListener(listener) { + state.storageListeners.add(listener) + }, + removeListener(listener) { + state.storageListeners.delete(listener) + }, + }, + }, + }, + } + `, +} + +export async function resolve(specifier, context, nextResolve) { + if (context.parentURL?.endsWith('/src/components/FloatingToolbar/index.jsx')) { + if (specifier === '../../hooks/use-clamp-window-size') { + return nextResolve('../../hooks/use-clamp-window-size.mjs', context) + } + + const stubUrl = floatingToolbarStubs.get(specifier) + if (stubUrl) return { url: stubUrl, shortCircuit: true } + } + + if (context.parentURL?.endsWith('/src/hooks/use-config.mjs')) { + const stubUrl = useConfigStubs.get(specifier) + if (stubUrl) return { url: stubUrl, shortCircuit: true } + } + + return nextResolve(specifier, context) +} + +export async function load(url, context, nextLoad) { + if (url.startsWith('test:subscription-')) { + return { + shortCircuit: true, + format: 'module', + source: sources[url], + } + } + + if (url.startsWith('file://') && url.endsWith('.jsx') && !url.includes('node_modules')) { + const source = await readFile(fileURLToPath(url), 'utf8') + const esbuild = await import('esbuild') + const result = await esbuild.transform(source, { + loader: 'jsx', + jsx: 'automatic', + jsxImportSource: 'preact', + }) + return { shortCircuit: true, format: 'module', source: result.code } + } + + return nextLoad(url, context) +} diff --git a/tests/unit/components/floating-toolbar-subscriptions.test.mjs b/tests/unit/components/floating-toolbar-subscriptions.test.mjs new file mode 100644 index 000000000..44f587c9b --- /dev/null +++ b/tests/unit/components/floating-toolbar-subscriptions.test.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict' +import { register } from 'node:module' +import { cwd } from 'node:process' +import { after, afterEach, before, test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { JSDOM } from 'jsdom' +import { h, render } from 'preact' +import { act } from 'preact/test-utils' + +register( + './tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs', + pathToFileURL(cwd() + '/').href, +) + +let dom +let FloatingToolbar +const originalDescriptors = new Map() +const globalNames = ['window', 'document', 'Node', 'HTMLElement'] +const toolbarContainers = new Set() + +const getCapture = (options) => (typeof options === 'boolean' ? options : Boolean(options?.capture)) + +const createListenerTracker = () => { + const registrations = [] + const matches = (registration, listener, options) => + registration.listener === listener && registration.capture === getCapture(options) + + return { + add(listener, options) { + if (registrations.some((registration) => matches(registration, listener, options))) return + registrations.push({ listener, capture: getCapture(options) }) + }, + remove(listener, options) { + const index = registrations.findIndex((registration) => + matches(registration, listener, options), + ) + if (index !== -1) registrations.splice(index, 1) + }, + snapshot() { + return registrations.map((registration) => ({ ...registration })) + }, + has(target) { + return registrations.some( + (registration) => + registration.listener === target.listener && registration.capture === target.capture, + ) + }, + clear() { + registrations.length = 0 + }, + get size() { + return registrations.length + }, + } +} + +const resizeListeners = createListenerTracker() +const selectionListeners = createListenerTracker() + +const waitFor = async (predicate, message) => { + for (let attempt = 0; attempt < 50; ++attempt) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, 0)) + } + assert.fail(message) +} + +const sameListenerRegistration = (left, right) => + left.listener === right.listener && left.capture === right.capture + +const getNewListener = (listeners, previousListeners, label) => { + const addedListeners = listeners + .snapshot() + .filter( + (listener) => + !previousListeners.some((previousListener) => + sameListenerRegistration(listener, previousListener), + ), + ) + assert.equal(addedListeners.length, 1, `expected one new ${label} listener`) + return addedListeners[0] +} + +const getNewSetEntry = (entries, previousEntries, label) => { + const addedEntries = [...entries].filter((entry) => !previousEntries.has(entry)) + assert.equal(addedEntries.length, 1, `expected one new ${label} listener`) + return addedEntries[0] +} + +const createToolbar = async (id) => { + const state = globalThis.__FLOATING_SUBSCRIPTION_TEST__ + const previousResizeListeners = resizeListeners.snapshot() + const previousSelectionListeners = selectionListeners.snapshot() + const previousStorageListeners = new Set(state.storageListeners) + const container = document.createElement('div') + document.body.append(container) + toolbarContainers.add(container) + + await act(async () => { + render( + h(FloatingToolbar, { + session: { id }, + selection: 'selected text', + container, + triggered: true, + closeable: true, + dockable: false, + prompt: 'prompt', + }), + container, + ) + await Promise.resolve() + await Promise.resolve() + }) + + const getCloseCallback = () => globalThis.__FLOATING_SUBSCRIPTION_TEST__.onCloseBySession.get(id) + await waitFor( + () => typeof getCloseCallback() === 'function', + `close callback was not rendered for ${id}`, + ) + + const onClose = getCloseCallback() + return { + container, + listeners: { + resize: getNewListener(resizeListeners, previousResizeListeners, 'resize'), + selection: getNewListener(selectionListeners, previousSelectionListeners, 'selection'), + storage: getNewSetEntry(state.storageListeners, previousStorageListeners, 'storage'), + }, + close: () => { + onClose() + }, + } +} + +before(async () => { + dom = new JSDOM('', { url: 'https://example.com/' }) + + for (const name of globalNames) { + originalDescriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)) + Object.defineProperty(globalThis, name, { + configurable: true, + value: dom.window[name], + }) + } + + const originalWindowAddEventListener = window.addEventListener.bind(window) + const originalWindowRemoveEventListener = window.removeEventListener.bind(window) + window.addEventListener = (type, listener, options) => { + if (type === 'resize') resizeListeners.add(listener, options) + return originalWindowAddEventListener(type, listener, options) + } + window.removeEventListener = (type, listener, options) => { + if (type === 'resize') resizeListeners.remove(listener, options) + return originalWindowRemoveEventListener(type, listener, options) + } + + const originalDocumentAddEventListener = document.addEventListener.bind(document) + const originalDocumentRemoveEventListener = document.removeEventListener.bind(document) + document.addEventListener = (type, listener, options) => { + if (type === 'selectionchange') selectionListeners.add(listener, options) + return originalDocumentAddEventListener(type, listener, options) + } + document.removeEventListener = (type, listener, options) => { + if (type === 'selectionchange') selectionListeners.remove(listener, options) + return originalDocumentRemoveEventListener(type, listener, options) + } + + globalThis.__FLOATING_SUBSCRIPTION_TEST__ = { + onCloseBySession: new Map(), + storageListeners: new Set(), + } + ;({ default: FloatingToolbar } = await import( + '../../../src/components/FloatingToolbar/index.jsx' + )) +}) + +afterEach(() => { + act(() => { + for (const container of toolbarContainers) render(null, container) + }) + for (const container of toolbarContainers) container.remove() + toolbarContainers.clear() + document.body.replaceChildren() + resizeListeners.clear() + selectionListeners.clear() + globalThis.__FLOATING_SUBSCRIPTION_TEST__.onCloseBySession.clear() + globalThis.__FLOATING_SUBSCRIPTION_TEST__.storageListeners.clear() +}) + +after(() => { + dom.window.close() + delete globalThis.__FLOATING_SUBSCRIPTION_TEST__ + + for (const [name, descriptor] of originalDescriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else delete globalThis[name] + } +}) + +test('listener tracking keeps registrations when capture does not match', () => { + const listener = () => {} + window.addEventListener('resize', listener, { capture: true }) + try { + window.removeEventListener('resize', listener, { capture: false }) + assert.equal(resizeListeners.size, 1) + } finally { + window.removeEventListener('resize', listener, { capture: true }) + } + + assert.equal(resizeListeners.size, 0) +}) + +test('closing a floating toolbar removes its real hook subscriptions', async () => { + const state = globalThis.__FLOATING_SUBSCRIPTION_TEST__ + const toolbar = await createToolbar('one') + + assert.equal(resizeListeners.size, 1) + assert.equal(selectionListeners.size, 1) + assert.equal(state.storageListeners.size, 1) + assert.equal(toolbar.listeners.resize.capture, false) + assert.equal(toolbar.listeners.selection.capture, false) + + act(() => toolbar.close()) + + assert.equal(toolbar.container.isConnected, false) + assert.equal(resizeListeners.size, 0) + assert.equal(selectionListeners.size, 0) + assert.equal(state.storageListeners.size, 0) +}) + +test('closing one toolbar leaves another toolbar subscriptions active', async () => { + const state = globalThis.__FLOATING_SUBSCRIPTION_TEST__ + const first = await createToolbar('first') + const second = await createToolbar('second') + + assert.equal(resizeListeners.size, 2) + assert.equal(selectionListeners.size, 2) + assert.equal(state.storageListeners.size, 2) + + act(() => first.close()) + + assert.equal(first.container.isConnected, false) + assert.equal(second.container.isConnected, true) + assert.equal(resizeListeners.has(first.listeners.resize), false) + assert.equal(selectionListeners.has(first.listeners.selection), false) + assert.equal(state.storageListeners.has(first.listeners.storage), false) + assert.equal(resizeListeners.has(second.listeners.resize), true) + assert.equal(selectionListeners.has(second.listeners.selection), true) + assert.equal(state.storageListeners.has(second.listeners.storage), true) + + act(() => second.close()) + + assert.equal(resizeListeners.size, 0) + assert.equal(selectionListeners.size, 0) + assert.equal(state.storageListeners.size, 0) +})