Something went wrong. Try again.
fork of witchsky forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549import {createElement, StrictMode} from 'react'import uuid from 'react-native-uuid'import {renderHook} from '@testing-library/react-native'import {Editor} from '@tiptap/core'import {Document} from '@tiptap/extension-document'import {Paragraph} from '@tiptap/extension-paragraph'import {Text} from '@tiptap/extension-text'import {JSDOM} from 'jsdom'
import {getSessionId, readSessionRecord} from '#/analytics/identifiers/session'import {useSessionActivity as useNativeSessionActivity} from '#/analytics/useSessionActivity/index'import { observeSessionActivity, useSessionActivity,} from '#/analytics/useSessionActivity/index.web'import {device} from '#/storage'
let mockRaw: string | undefinedjest.mock('#/env', () => ({IS_NATIVE: false}))jest.mock('#/lib/appState', () => ({onAppStateChange: jest.fn()}))jest.mock('react-native-uuid', () => ({ __esModule: true, default: {v4: jest.fn(() => 'rotated')},}))jest.mock('#/storage', () => ({ device: { getRaw: jest.fn(() => mockRaw), set: jest.fn((_key, data) => { mockRaw = JSON.stringify({data}) }), },}))jest.mock('#/analytics/identifiers/sessionStorage', () => jest.requireActual('#/analytics/identifiers/sessionStorage/index.web'),)
const NOW = new Date('2026-09-22T12:00:00Z').getTime()const TTL = 30 * 60 * 1e3const INPUT_EVENTS = [ 'keydown', 'pointerdown', 'click', 'beforeinput', 'input', 'scroll', 'popstate',] as constlet focused: booleanlet visible: DocumentVisibilityStatelet cleanup: (() => void) | undefinedlet trustedEvent: Event | undefinedlet listeners: Map<string, EventListener>
/** JSDOM cannot generate trusted input or scroll events. */function send(type: string, target: EventTarget = window, trusted = true) { const EventClass = type === 'click' ? dom.window.MouseEvent : type === 'beforeinput' || type === 'input' ? dom.window.InputEvent : dom.window.Event const event = new EventClass(type) trustedEvent = trusted ? event : undefined target.dispatchEvent(event) trustedEvent = undefined}
/** Exercise the composer's actual editor dependencies with controlled DOM events. */function createEditor(onUpdate: () => void) { const element = document.createElement('div') document.body.append(element) return new Editor({ element, extensions: [Document, Paragraph, Text], content: '<p></p>', injectCSS: false, onUpdate, })}
/** Use a separate DOM so Expo's native Jest setup keeps its own globals. */const dom = new JSDOM('<!doctype html><html><body></body></html>')const domGlobals = { window: dom.window, document: dom.window.document, innerHeight: dom.window.innerHeight, getComputedStyle: dom.window.getComputedStyle.bind(dom.window),}const originalGlobals = Object.keys(domGlobals).map( key => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,)beforeAll(() => { for (const [key, value] of Object.entries(domGlobals)) { Object.defineProperty(globalThis, key, {value, configurable: true}) }})afterAll(() => { for (const [key, descriptor] of originalGlobals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor) else Reflect.deleteProperty(globalThis, key) } dom.window.close()})
beforeEach(() => { jest.useFakeTimers() jest.setSystemTime(NOW) jest.clearAllMocks() mockRaw = JSON.stringify({data: {id: 'original', lastEventAt: NOW}}) focused = true visible = 'visible' jest.spyOn(document, 'hasFocus').mockImplementation(() => focused) jest .spyOn(document, 'visibilityState', 'get') .mockImplementation(() => visible) listeners = new Map() const add = window.addEventListener.bind(window) const remove = window.removeEventListener.bind(window) jest .spyOn(window, 'addEventListener') .mockImplementation((name, listener, options) => { if (!listener) return const wrapped: EventListener = event => { const input = new Proxy(event, { get: (target, key) => key === 'isTrusted' ? event === trustedEvent : Reflect.get(target, key, target), }) if (typeof listener === 'function') listener(input) else listener.handleEvent(input) } listeners.set(name, wrapped) add(name, wrapped, options) }) jest .spyOn(window, 'removeEventListener') .mockImplementation((name, listener, options) => { if (!listener) return remove(name, listeners.get(name) ?? listener, options) listeners.delete(name) })})afterEach(() => { cleanup?.() cleanup = undefined document.body.innerHTML = '' jest.restoreAllMocks() jest.useRealTimers()})
it.each(['hidden', 'unfocused'])( 'does not renew on %s startup or passive rerenders', state => { visible = state === 'hidden' ? 'hidden' : 'visible' focused = state === 'hidden' jest.setSystemTime(NOW + TTL) const view = renderHook(useSessionActivity) cleanup = view.unmount view.rerender({}) expect(getSessionId()).toBe('original') for (const event of [...INPUT_EVENTS, 'focus', 'visibilitychange']) send(event) expect(device.set).not.toHaveBeenCalled() expect(readSessionRecord()?.lastEventAt).toBe(NOW) },)
it.each(['focus-first', 'visibility-first'])( 'gates and deduplicates a return (%s)', order => { focused = false visible = 'hidden' cleanup = observeSessionActivity() jest.setSystemTime(NOW + TTL) if (order === 'focus-first') { focused = true send('focus') expect(getSessionId()).toBe('original') visible = 'visible' send('visibilitychange', document) } else { visible = 'visible' send('visibilitychange', document) expect(getSessionId()).toBe('original') focused = true send('focus') } expect(getSessionId()).toBe('rotated') send('focus') send('visibilitychange', document) expect(uuid.v4).toHaveBeenCalledTimes(1) },)
it.each(['focus', 'visibilitychange', 'pageshow'])( 'checks a %s return even if suspension delivered no departure event', type => { cleanup = observeSessionActivity() jest.setSystemTime(NOW + TTL) send(type) expect(getSessionId()).toBe('rotated') send(type) expect(uuid.v4).toHaveBeenCalledTimes(1) },)
it('does not count a late background/blur notification as new activity', () => { cleanup = observeSessionActivity() jest.setSystemTime(NOW + TTL * 5) focused = false send('blur') visible = 'hidden' send('visibilitychange', document) expect(readSessionRecord()?.lastEventAt).toBe(NOW) expect(device.set).toHaveBeenCalledTimes(1) visible = 'visible' focused = true send('focus') expect(getSessionId()).toBe('rotated')})
it.each(INPUT_EVENTS)( 'rotates before %s metadata is captured, keeping old snapshots intact', type => { cleanup = observeSessionActivity() const target = document.createElement('div') document.body.append(target) const snapshots = [{sessionId: getSessionId()}] const captureMetric = () => snapshots.push({sessionId: getSessionId()}) // Even an earlier document capture listener is below window capture. document.addEventListener(type, captureMetric, true) jest.setSystemTime(NOW + TTL) send(type, target) expect(snapshots).toEqual([{sessionId: 'original'}, {sessionId: 'rotated'}]) document.removeEventListener(type, captureMetric, true) },)
describe.each(['click', 'beforeinput', 'input'])( '%s without pointerdown or keydown', type => { it.each([TTL - 1, TTL, TTL + 1])( 'renews or rotates at %d ms before target handling', elapsed => { cleanup = observeSessionActivity() const target = document.createElement( type === 'click' ? 'button' : 'textarea', ) document.body.append(target) const snapshots = [{sessionId: getSessionId()}] target.addEventListener(type, () => snapshots.push({sessionId: getSessionId()}), ) jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + elapsed) send(type, target) const id = elapsed < TTL ? 'original' : 'rotated' expect(snapshots).toEqual([{sessionId: 'original'}, {sessionId: id}]) expect(readSessionRecord()).toEqual({id, lastEventAt: NOW + elapsed}) expect(device.set).toHaveBeenCalledTimes(1) expect(uuid.v4).toHaveBeenCalledTimes(elapsed < TTL ? 0 : 1) }, ) },)
it.each([ ['pointerdown', 'click'], ['keydown', 'beforeinput', 'input'], ['beforeinput', 'input'],])('throttles the entire %j interaction sequence', (...events) => { cleanup = observeSessionActivity() jest.mocked(device.getRaw).mockClear() jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + TTL) for (const type of events) { send(type) jest.setSystemTime(Date.now() + 100) } expect(device.getRaw).toHaveBeenCalledTimes(1) expect(device.set).toHaveBeenCalledTimes(1) expect(uuid.v4).toHaveBeenCalledTimes(1) expect(jest.getTimerCount()).toBe(0)})
it('rotates before RN Web PressResponder handles a click-only activation', () => { const PressResponder = jest.requireActual< new (config: {onPress: () => void; onPressStart: () => void}) => { getEventHandlers(): {onClick: (event: MouseEvent) => void} reset(): void } >('react-native-web/dist/cjs/modules/usePressEvents/PressResponder') cleanup = observeSessionActivity() const snapshots = [{sessionId: getSessionId()}] const onPressStart = jest.fn() const responder = new PressResponder({ onPress: () => snapshots.push({sessionId: getSessionId()}), onPressStart, }) const button = document.createElement('button') document.body.append(button) button.addEventListener('click', responder.getEventHandlers().onClick) jest.setSystemTime(NOW + TTL) send('click', button) expect(onPressStart).not.toHaveBeenCalled() expect(snapshots).toEqual([{sessionId: 'original'}, {sessionId: 'rotated'}]) responder.reset()})
it.each(['beforeinput', 'input'])( 'rotates before a Tiptap update triggered by %s', type => { cleanup = observeSessionActivity() const snapshots = [{sessionId: getSessionId()}] const editor = createEditor(() => snapshots.push({sessionId: getSessionId()}), ) try { editor.setOptions({ editorProps: { handleDOMEvents: { [type]: () => { editor.commands.insertContent('entered text') return true }, }, }, }) jest.setSystemTime(NOW + TTL) send(type, editor.view.dom) expect(editor.getText()).toBe('entered text') expect(snapshots).toEqual([ {sessionId: 'original'}, {sessionId: 'rotated'}, ]) } finally { editor.destroy() } },)
it('does not count programmatic Tiptap updates as activity', () => { cleanup = observeSessionActivity() const snapshots: string[] = [] const editor = createEditor(() => snapshots.push(getSessionId())) try { jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + TTL) editor.commands.setContent('<p>controlled content</p>', true) expect(snapshots).toEqual(['original']) expect(device.set).not.toHaveBeenCalled() expect(readSessionRecord()?.lastEventAt).toBe(NOW) } finally { editor.destroy() }})
it('ignores programmatic values, element.click(), and script-dispatched input', () => { cleanup = observeSessionActivity() const field = document.createElement('textarea') const button = document.createElement('button') document.body.append(field, button) jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + TTL) field.value = 'controlled content' button.click() send('beforeinput', field, false) send('input', field, false) expect(device.set).not.toHaveBeenCalled() expect(getSessionId()).toBe('original')})
it('ignores synthetic events', () => { cleanup = observeSessionActivity() jest.setSystemTime(NOW + TTL) for (const type of INPUT_EVENTS) { send(type, window, false) } expect(getSessionId()).toBe('original') expect(device.set).toHaveBeenCalledTimes(1)})
it.each(['scroll', 'input'])( 'throttles storage reads and writes before recording frequent %s events', type => { cleanup = observeSessionActivity() jest.mocked(device.getRaw).mockClear() jest.mocked(device.set).mockClear() for (let i = 1; i <= 150; i++) { jest.setSystemTime(NOW + i * 100) send(type) const recordings = Math.floor((i * 100) / 5_000) expect(device.getRaw).toHaveBeenCalledTimes(recordings) expect(device.set).toHaveBeenCalledTimes(recordings) } expect(jest.getTimerCount()).toBe(0) jest.advanceTimersByTime(TTL * 2) expect(device.getRaw).toHaveBeenCalledTimes(3) expect(device.set).toHaveBeenCalledTimes(3) },)
it.each([0, 4_999, 5_000, 5_001])( 'checks the local throttle before storage at %d ms', elapsed => { cleanup = observeSessionActivity() jest.mocked(device.getRaw).mockClear() jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + elapsed) send('scroll') expect(device.getRaw).toHaveBeenCalledTimes(elapsed < 5_000 ? 0 : 1) expect(device.set).toHaveBeenCalledTimes(elapsed < 5_000 ? 0 : 1) },)
it('can expire less than five seconds early relative to throttled activity', () => { cleanup = observeSessionActivity() jest.setSystemTime(NOW + 4_999) send('scroll') expect(readSessionRecord()?.lastEventAt).toBe(NOW) expect(device.set).toHaveBeenCalledTimes(1) jest.setSystemTime(NOW + TTL) send('focus') expect(getSessionId()).toBe('rotated')})
it.each(['keydown', 'click', 'beforeinput', 'input'])( 'uses local throttle time without caching the shared ID during %s', type => { cleanup = observeSessionActivity() jest.mocked(device.getRaw).mockClear() jest.mocked(device.set).mockClear() jest.setSystemTime(NOW + 4_500) mockRaw = JSON.stringify({data: {id: 'other-tab', lastEventAt: Date.now()}}) send(type) expect(device.getRaw).not.toHaveBeenCalled() expect(device.set).not.toHaveBeenCalled() // Metrics still adopt the other tab's ID even inside the activity throttle. expect(getSessionId()).toBe('other-tab') jest.setSystemTime(NOW + 5_000) send(type) expect(device.set).toHaveBeenCalledWith(['analyticsSession'], { id: 'other-tab', lastEventAt: Date.now(), }) },)
it('bypasses the local throttle after clock rollback', () => { cleanup = observeSessionActivity() jest.setSystemTime(NOW - 1) send('keydown') expect(device.getRaw).toHaveBeenCalledTimes(2) expect(device.set).toHaveBeenCalledTimes(2) expect(readSessionRecord()).toEqual({id: 'original', lastEventAt: NOW - 1}) expect(uuid.v4).not.toHaveBeenCalled()})
it('counts nested scrolls without preceding keyboard or pointer input', () => { cleanup = observeSessionActivity() const outer = document.createElement('div') const nested = document.createElement('div') outer.append(nested) document.body.append(outer) for (let minute = 1; minute <= 40; minute++) { jest.setSystemTime(NOW + minute * 60_000) send('scroll', nested) } expect(getSessionId()).toBe('original') expect(readSessionRecord()?.lastEventAt).toBe(NOW + 40 * 60_000)})
it('ignores blur and unfocused scroll without renewing activity', () => { cleanup = observeSessionActivity() jest.advanceTimersByTime(5_000) send('keydown') expect(jest.getTimerCount()).toBe(0) jest.advanceTimersByTime(100) focused = false send('blur') expect(readSessionRecord()?.lastEventAt).toBe(NOW + 5_000) expect(jest.getTimerCount()).toBe(0) send('scroll') expect(jest.getTimerCount()).toBe(0)})
it('ignores blur even if hasFocus has not updated yet', () => { cleanup = observeSessionActivity() jest.advanceTimersByTime(5_000) send('keydown') send('blur') expect(readSessionRecord()?.lastEventAt).toBe(NOW + 5_000) expect(jest.getTimerCount()).toBe(0) jest.setSystemTime(NOW + 5_000 + TTL) send('focus') expect(getSessionId()).toBe('rotated')})
it('ignores element focus changes and returns from page cache using wall-clock time', () => { cleanup = observeSessionActivity() jest.setSystemTime(NOW + TTL) send('focus', document) expect(getSessionId()).toBe('original') send('pagehide') expect(device.set).toHaveBeenCalledTimes(1) send('pageshow') expect(getSessionId()).toBe('rotated')})
it('cleans up all capture/passive listeners across Strict Mode and remounts', () => { const first = renderHook(useSessionActivity, { wrapper: ({children}) => createElement(StrictMode, null, children), }) expect([...listeners.keys()]).toEqual([ ...INPUT_EVENTS, 'focus', 'visibilitychange', 'pageshow', ]) for (const [, , options] of jest.mocked(window.addEventListener).mock.calls) { expect(options).toEqual({capture: true, passive: true}) } jest.advanceTimersByTime(5_000) send('keydown') expect(jest.getTimerCount()).toBe(0) first.unmount() expect(listeners.size).toBe(0) expect(jest.getTimerCount()).toBe(0) expect(readSessionRecord()?.lastEventAt).toBe(NOW + 5_000) jest.setSystemTime(NOW + TTL + 5_000) for (const type of INPUT_EVENTS) send(type) expect(getSessionId()).toBe('original') const second = renderHook(useSessionActivity) cleanup = second.unmount expect(getSessionId()).toBe('rotated') const writes = jest.mocked(device.set).mock.calls.length for (const type of INPUT_EVENTS) send(type) expect(device.set).toHaveBeenCalledTimes(writes)})
it('keeps the default/native hook free of DOM access', () => { const visibility = jest.spyOn(document, 'visibilityState', 'get') renderHook(useNativeSessionActivity).unmount() expect(visibility).not.toHaveBeenCalled() expect(window.addEventListener).not.toHaveBeenCalled() expect(device.set).not.toHaveBeenCalled()})