mirror of
https://github.com/vernu/textbee.git
synced 2026-08-01 01:48:17 -04:00
The effect depended on the whole session object. SessionProvider returns a new object on every refetch, and React compares deps by reference, so an identical session still ran the cleanup and the body again: the widget was destroyed and its script re-injected even though nothing about the user had changed, closing any chat the user had open. It now depends on the individual strings the effect actually reads, which compare by value, so it only re-initialises when the metadata genuinely changes. Two related leaks fixed at the same time: - cleanup called destroy() but left the script tag in the document, so every re-run added another one for the life of the page. It is now removed. - a script whose src is already cached can finish loading after cleanup ran, and initialising then left a widget behind that nothing would destroy. That late load is now ignored. Tests cover all four behaviours and fail against the previous version. Also gives the API guide page focus before the clipboard test: writeText rejects on an unfocused document, which is where a page sits while other workers run, and granting clipboard permissions does not cover it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { render } from '@testing-library/react'
|
|
import SupportHQWidget from './support-hq-widget'
|
|
|
|
const useSessionMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('next-auth/react', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('next-auth/react')>()
|
|
return { ...actual, useSession: useSessionMock }
|
|
})
|
|
|
|
const init = vi.fn()
|
|
const destroy = vi.fn()
|
|
|
|
// A fresh object every call, the way SessionProvider hands one back after a
|
|
// refetch even when the underlying user has not changed.
|
|
const sessionWith = (overrides: Record<string, unknown> = {}) => ({
|
|
data: {
|
|
user: {
|
|
id: 'user_1',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
phone: '',
|
|
...overrides,
|
|
},
|
|
},
|
|
})
|
|
|
|
const scripts = () =>
|
|
document.querySelectorAll('script[src*="supporthq-widget.js"]')
|
|
|
|
// jsdom does not fetch external scripts, so loading is simulated.
|
|
const fireLoad = () =>
|
|
scripts().forEach((s) => (s as HTMLScriptElement).onload?.(new Event('load')))
|
|
|
|
beforeEach(() => {
|
|
init.mockClear()
|
|
destroy.mockClear()
|
|
// @ts-ignore
|
|
window.SupportHQWidget = { init, destroy }
|
|
})
|
|
|
|
afterEach(() => {
|
|
document
|
|
.querySelectorAll('script[src*="supporthq-widget.js"]')
|
|
.forEach((s) => s.remove())
|
|
})
|
|
|
|
describe('SupportHQWidget', () => {
|
|
it('injects the widget script once and initialises with the user metadata', () => {
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
render(<SupportHQWidget />)
|
|
|
|
expect(scripts()).toHaveLength(1)
|
|
|
|
fireLoad()
|
|
expect(init).toHaveBeenCalledTimes(1)
|
|
expect(init.mock.calls[0][0].metadata).toEqual({
|
|
userId: 'user_1',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
phone: '',
|
|
})
|
|
})
|
|
|
|
// The reported defect: the effect depended on the session object, so a
|
|
// refetch that returned identical data still destroyed and re-injected the
|
|
// widget, closing any chat the user had open.
|
|
it('does not reinitialise when a refetch returns identical data', () => {
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
const { rerender } = render(<SupportHQWidget />)
|
|
fireLoad()
|
|
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
rerender(<SupportHQWidget />)
|
|
|
|
expect(scripts()).toHaveLength(1)
|
|
expect(destroy).not.toHaveBeenCalled()
|
|
expect(init).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('reinitialises when the user metadata actually changes', () => {
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
const { rerender } = render(<SupportHQWidget />)
|
|
fireLoad()
|
|
|
|
useSessionMock.mockReturnValue(sessionWith({ email: 'new@example.com' }))
|
|
rerender(<SupportHQWidget />)
|
|
fireLoad()
|
|
|
|
expect(destroy).toHaveBeenCalledTimes(1)
|
|
expect(init).toHaveBeenCalledTimes(2)
|
|
expect(init.mock.calls[1][0].metadata.email).toBe('new@example.com')
|
|
})
|
|
|
|
// destroy() tears down the widget but leaves the tag, so without an explicit
|
|
// removal every re-run left another one behind.
|
|
it('leaves no script tags behind when it unmounts', () => {
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
const { unmount } = render(<SupportHQWidget />)
|
|
fireLoad()
|
|
|
|
unmount()
|
|
|
|
expect(scripts()).toHaveLength(0)
|
|
expect(destroy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not initialise if the script loads after cleanup', () => {
|
|
useSessionMock.mockReturnValue(sessionWith())
|
|
const { unmount } = render(<SupportHQWidget />)
|
|
|
|
const pending = scripts()[0] as HTMLScriptElement
|
|
unmount()
|
|
// A cached src can resolve after the effect was torn down; initialising
|
|
// then would leave a widget that nothing destroys.
|
|
pending.onload?.(new Event('load'))
|
|
|
|
expect(init).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('omits metadata when nobody is signed in', () => {
|
|
useSessionMock.mockReturnValue({ data: null })
|
|
render(<SupportHQWidget />)
|
|
fireLoad()
|
|
|
|
expect(init).toHaveBeenCalledTimes(1)
|
|
expect(init.mock.calls[0][0]).not.toHaveProperty('metadata')
|
|
})
|
|
})
|