-
Notifications
You must be signed in to change notification settings - Fork 876
Verify floating toolbar subscription cleanup with real hooks #1080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
PeterDaveHello
wants to merge
1
commit into
ChatGPTBox-dev:master
Choose a base branch
from
PeterDaveHello:test/floating-toolbar-subscription-cleanup
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+360
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
103 changes: 103 additions & 0 deletions
103
tests/setup/floating-toolbar-subscriptions-loader-hooks.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
257 changes: 257 additions & 0 deletions
257
tests/unit/components/floating-toolbar-subscriptions.test.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| import assert from 'node:assert/strict' | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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) | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
| 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('<!doctype html><html><body></body></html>', { 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) | ||
| }) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.