From e919b2bf8a69b3570e6a0254faa98e0cb3705d1b Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:25:22 +0800 Subject: [PATCH] Verify floating toolbar subscription cleanup with real hooks Render FloatingToolbar with its real configuration and window-size hooks to verify that close teardown removes the storage, resize, and selection subscriptions owned by each instance. Track DOM event registrations by callback identity and capture mode, use the production config-storage listener factory for lifecycle fidelity, and keep the capture-mismatch regression self-cleaning when assertions fail. --- ...ing-toolbar-subscriptions-loader-hooks.mjs | 103 +++++++ .../floating-toolbar-subscriptions.test.mjs | 257 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs create mode 100644 tests/unit/components/floating-toolbar-subscriptions.test.mjs 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 00000000..dc364844 --- /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 00000000..44f587c9 --- /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) +})