diff --git a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx new file mode 100644 index 00000000..4bb525f9 --- /dev/null +++ b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx @@ -0,0 +1,324 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ChallengeModal from '../challenge-modal'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + abandonCurrentChallengeMock: vi.fn().mockResolvedValue(undefined), + account: { + author: { + address: '0xabc123', + }, + } as Record, + challenges: [] as Array<{ challenge: any; id: number }>, + commentsByCid: {} as Record, + publicationPreview: 'preview body', + publicationType: 'post', + removeChallengeMock: vi.fn(), + springStartMock: vi.fn(), + theme: 'dark', + votePreview: 'upvote', +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'challenge_counter') { + return `${options?.index}/${options?.total}`; + } + return key; + }, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), +})); + +vi.mock('../../../lib/utils/challenge-utils', () => ({ + getPublicationPreview: () => testState.publicationPreview, + getPublicationType: () => testState.publicationType, + getVotePreview: () => testState.votePreview, +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => false, +})); + +vi.mock('../../../hooks/use-theme', () => ({ + default: () => [testState.theme], +})); + +vi.mock('../../../stores/use-challenges-store', () => ({ + default: () => ({ + abandonCurrentChallenge: testState.abandonCurrentChallengeMock, + challenges: testState.challenges, + removeChallenge: testState.removeChallengeMock, + }), +})); + +vi.mock('@react-spring/web', async () => { + const React = await vi.importActual('react'); + return { + animated: { + div: React.forwardRef(({ children, style, ...props }: any, ref) => createElement('div', { ...props, ref, style: { touchAction: style?.touchAction } }, children)), + }, + useSpring: () => [ + { + x: { get: () => 120 }, + y: { get: () => 60 }, + }, + { start: testState.springStartMock }, + ], + }; +}); + +vi.mock('@use-gesture/react', () => ({ + useDrag: () => () => ({}), +})); + +let alertSpy: ReturnType; +let container: HTMLDivElement; +let consoleErrorSpy: ReturnType; +let postMessageMock: ReturnType; +let root: Root; + +const createPublication = () => ({ + author: { displayName: 'Alice' }, + content: 'Publication content', + link: 'https://example.com/link', + parentCid: 'parent-1', + publishChallengeAnswers: vi.fn(), + shortSubplebbitAddress: 'mu', + subplebbitAddress: 'music-posting.eth', + title: 'Subject', +}); + +const createStoredChallenge = (challenge: any, publication = createPublication(), publicationTarget?: Record) => ({ + challenge: [{ challenges: Array.isArray(challenge) ? challenge : [challenge] }, publication, publicationTarget], + id: 1, +}); + +const renderModal = async () => { + await act(async () => { + root.render(createElement(ChallengeModal)); + }); +}; + +const dispatchInput = async (element: HTMLInputElement, value: string) => { + await act(async () => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +const clickButton = async (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +describe('ChallengeModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.abandonCurrentChallengeMock.mockReset().mockResolvedValue(undefined); + testState.account = { + author: { + address: '0xabc123', + }, + }; + testState.challenges = []; + testState.commentsByCid = { + 'parent-1': { + author: { + shortAddress: '0xparent', + }, + }, + }; + testState.publicationPreview = 'preview body'; + testState.publicationType = 'post'; + testState.removeChallengeMock.mockReset(); + testState.springStartMock.mockReset(); + testState.theme = 'dark'; + testState.votePreview = 'upvote'; + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + postMessageMock = vi.fn(); + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + configurable: true, + get: () => ({ + postMessage: postMessageMock, + }), + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + alertSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('renders nothing when there are no queued challenges', async () => { + await renderModal(); + expect(container.innerHTML).toBe(''); + }); + + it('submits a text challenge answer on Enter and closes the modal', async () => { + const publication = createPublication(); + testState.publicationType = 'reply'; + testState.challenges = [ + createStoredChallenge( + { + challenge: '2 + 2', + type: 'text/plain', + }, + publication, + ), + ]; + + await renderModal(); + expect(container.textContent).toContain('Challenge for reply'); + expect(container.textContent).toContain('1/1'); + expect(container.querySelector('textarea')?.textContent ?? container.textContent).toContain('Publication content'); + + const input = container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]'); + expect(input).not.toBeNull(); + + await dispatchInput(input as HTMLInputElement, '4'); + await act(async () => { + input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + }); + + expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['4']); + expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); + }); + + it('supports multi-step image challenges with next and previous navigation', async () => { + const publication = createPublication(); + testState.challenges = [ + createStoredChallenge( + [ + { + challenge: 'first answer', + type: 'text/plain', + }, + { + challenge: 'base64-image', + type: 'image/png', + }, + ], + publication, + ), + ]; + + await renderModal(); + expect(container.textContent).toContain('1/2'); + + const input = container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]'); + await dispatchInput(input as HTMLInputElement, 'step one'); + await act(async () => { + input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + }); + + expect(container.textContent).toContain('2/2'); + expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,base64-image'); + + await clickButton('previous'); + expect(container.textContent).toContain('1/2'); + + await clickButton('next'); + await dispatchInput(container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]') as HTMLInputElement, 'step two'); + await clickButton('submit'); + + expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['step one', 'step two']); + expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); + }); + + it('opens iframe challenges, injects the theme, and completes them', async () => { + const publication = createPublication(); + testState.challenges = [ + createStoredChallenge( + { + challenge: 'https://mintpass.org/auth?user={userAddress}', + type: 'url/iframe', + }, + publication, + ), + ]; + + await renderModal(); + expect(container.textContent).toContain('mu wants to open mintpass.org'); + + await clickButton('Open'); + + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + expect(iframe?.getAttribute('src')).toContain('https://mintpass.org/auth?user=0xabc123&theme=dark'); + + await act(async () => { + iframe?.dispatchEvent(new Event('load', { bubbles: true })); + }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + source: 'plebbit-5chan', + theme: 'dark', + type: 'plebbit-theme', + }, + 'https://mintpass.org', + ); + + await clickButton('Done'); + expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']); + expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); + }); + + it('alerts when iframe challenges need a signer address and the account is missing one', async () => { + testState.account = { author: { address: '' } }; + testState.challenges = [ + createStoredChallenge({ + challenge: 'https://mintpass.org/auth?user={userAddress}', + type: 'url/iframe', + }), + ]; + + await renderModal(); + await clickButton('Open'); + + expect(alertSpy).toHaveBeenCalledWith('Error: Unable to load challenge without your address. Please sign in and try again.'); + expect(container.querySelector('iframe')).toBeNull(); + }); + + it('abandons invalid iframe challenges and responds to Escape', async () => { + testState.challenges = [ + createStoredChallenge({ + challenge: 'http://example.com/unsafe', + type: 'url/iframe', + }), + ]; + + await renderModal(); + await clickButton('Open'); + + expect(alertSpy).toHaveBeenCalledWith('Error: Invalid URL for authentication challenge'); + expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce(); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + + expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/edit-menu/__tests__/edit-menu.test.tsx b/src/components/edit-menu/__tests__/edit-menu.test.tsx new file mode 100644 index 00000000..9081ae19 --- /dev/null +++ b/src/components/edit-menu/__tests__/edit-menu.test.tsx @@ -0,0 +1,335 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import EditMenu from '../edit-menu'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: { + author: { + address: '0xmod', + displayName: 'Moderator', + shortAddress: '0xmod', + }, + signer: { + address: '0xauthor', + }, + } as Record, + addChallengeMock: vi.fn(), + authorOptions: undefined as Record | undefined, + isMobile: false, + modOptions: undefined as Record | undefined, + privileges: { + isAccountCommentAuthor: false, + isAccountMod: false, + isCommentAuthorMod: false, + }, + publishAuthorEditMock: vi.fn().mockResolvedValue(undefined), + publishCommentModerationMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('react-i18next', () => ({ + Trans: ({ components, i18nKey }: { components?: Record; i18nKey: string }) => + createElement( + 'span', + { 'data-testid': `trans-${i18nKey}` }, + i18nKey, + components?.[1] ? React.cloneElement(components[1], { 'data-testid': 'ban-duration-input' }) : null, + ), + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@floating-ui/react', () => ({ + FloatingFocusManager: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), + FloatingPortal: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), + autoUpdate: () => undefined, + offset: () => ({}), + shift: () => ({}), + useClick: () => ({}), + useDismiss: () => ({}), + useFloating: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ({ + context: { + open, + onOpenChange, + }, + floatingStyles: {}, + refs: { + setFloating: () => undefined, + setReference: () => undefined, + }, + }), + useId: () => 'edit-menu-heading', + useInteractions: () => ({ + getFloatingProps: (props?: Record) => props || {}, + getReferenceProps: (props?: Record) => props || {}, + }), + useRole: () => ({}), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + usePublishCommentEdit: (options: Record) => { + testState.authorOptions = options; + return { publishCommentEdit: testState.publishAuthorEditMock }; + }, + usePublishCommentModeration: (options: Record) => { + testState.modOptions = options; + return { publishCommentModeration: testState.publishCommentModerationMock }; + }, +})); + +vi.mock('../../../hooks/use-author-privileges', () => ({ + default: () => testState.privileges, +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../stores/use-challenges-store', () => { + const hook = () => ({ challenges: [] }); + return { + default: Object.assign(hook, { + getState: () => ({ + addChallenge: testState.addChallengeMock, + }), + }), + }; +}); + +let alertSpy: ReturnType; +let confirmSpy: ReturnType; +let container: HTMLDivElement; +let root: Root; + +const basePost = { + author: { + address: '0xauthor', + displayName: 'Alice', + }, + cid: 'comment-1', + content: 'Original content', + deleted: false, + locked: false, + parentCid: undefined as string | undefined, + pinned: false, + postCid: 'post-1', + reason: '', + removed: false, + spoiler: false, + subplebbitAddress: 'music-posting.eth', +} as Record; + +const renderMenu = async (post = basePost) => { + await act(async () => { + root.render(createElement(EditMenu, { post } as any)); + }); +}; + +const click = async (element: Element | null | undefined) => { + await act(async () => { + element?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => { + await act(async () => { + const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +const openMenu = async () => { + await click(container.querySelector('span input[type="checkbox"]')); +}; + +const getCheckbox = (id: string) => container.querySelector(`#${id}`); + +const getLabelCheckbox = (text: string) => + Array.from(container.querySelectorAll('label input[type="checkbox"]')).find((candidate) => candidate.parentElement?.textContent?.includes(text)) ?? null; + +const clickButton = async (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); + await click(button); +}; + +describe('EditMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-08T00:00:00Z')); + testState.account = { + author: { + address: '0xmod', + displayName: 'Moderator', + shortAddress: '0xmod', + }, + signer: { + address: '0xauthor', + }, + }; + testState.authorOptions = undefined; + testState.isMobile = false; + testState.modOptions = undefined; + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: false, + isCommentAuthorMod: false, + }; + testState.publishAuthorEditMock.mockReset().mockResolvedValue(undefined); + testState.publishCommentModerationMock.mockReset().mockResolvedValue(undefined); + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + alertSpy.mockRestore(); + confirmSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('alerts when a user without privileges tries to edit a thread or reply', async () => { + await renderMenu(basePost); + await openMenu(); + expect(alertSpy).toHaveBeenCalledWith('cannot_edit_thread'); + + await renderMenu({ + ...basePost, + parentCid: 'parent-1', + }); + await openMenu(); + expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply'); + }); + + it('lets comment authors update content, deletion, and reason', async () => { + testState.privileges = { + isAccountCommentAuthor: true, + isAccountMod: false, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + + await click(getCheckbox('deleted')); + await click(getLabelCheckbox('Edit?')); + + const textarea = container.querySelector('textarea'); + const reasonInput = container.querySelector('input[type="text"]'); + expect(textarea).not.toBeNull(); + expect(reasonInput).not.toBeNull(); + + await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body'); + await dispatchInput(reasonInput as HTMLInputElement, 'cleanup'); + await clickButton('save'); + + expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce(); + expect(testState.publishCommentModerationMock).not.toHaveBeenCalled(); + expect(testState.authorOptions).toMatchObject({ + author: { + address: '0xauthor', + displayName: 'Alice', + }, + commentCid: 'comment-1', + content: 'Updated body', + deleted: true, + reason: 'cleanup', + spoiler: false, + subplebbitAddress: 'music-posting.eth', + }); + }); + + it('lets moderators change moderation flags, ban duration, and save them', async () => { + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: true, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + + await click(getCheckbox('removed')); + await click(getCheckbox('purged')); + await click(getCheckbox('locked')); + await click(getCheckbox('spoiler')); + await click(getCheckbox('pinned')); + await click(getCheckbox('banUser')); + + const banDurationInput = container.querySelector('[data-testid="ban-duration-input"]'); + const reasonInput = container.querySelector('input[type="text"]'); + expect(banDurationInput).not.toBeNull(); + expect(reasonInput).not.toBeNull(); + + await dispatchInput(banDurationInput as HTMLInputElement, '7'); + await dispatchInput(reasonInput as HTMLInputElement, 'rule violation'); + await clickButton('save'); + + expect(confirmSpy).toHaveBeenCalledWith('purge_confirm'); + expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce(); + expect(testState.modOptions).toMatchObject({ + author: { + address: '0xmod', + displayName: 'Moderator', + shortAddress: '0xmod', + }, + commentCid: 'comment-1', + subplebbitAddress: 'music-posting.eth', + }); + expect(testState.modOptions?.commentModeration).toMatchObject({ + reason: 'rule violation', + locked: true, + pinned: true, + purged: true, + removed: true, + spoiler: true, + author: { + banExpiresAt: Math.floor(new Date('2026-03-15T00:00:00Z').getTime() / 1000), + }, + }); + }); + + it('does not enable purge when the confirmation is rejected', async () => { + confirmSpy.mockReturnValue(false); + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: true, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + await click(getCheckbox('purged')); + + expect(getCheckbox('purged')?.checked).toBe(false); + expect(testState.modOptions?.commentModeration?.purged).toBe(false); + }); + + it('runs both the author edit and moderation publication paths when the user has both privileges', async () => { + testState.privileges = { + isAccountCommentAuthor: true, + isAccountMod: true, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + await clickButton('save'); + + expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce(); + expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx b/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx new file mode 100644 index 00000000..6217e048 --- /dev/null +++ b/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx @@ -0,0 +1,223 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: { + mediaIpfsGatewayUrl: 'https://media.old.example', + plebbitOptions: { + chainProviders: { + eth: { chainId: 1, urls: ['https://eth.old.example'] }, + sol: { chainId: 101, urls: ['https://sol.old.example'] }, + }, + httpRoutersOptions: ['https://router.old.example'], + ipfsGatewayUrls: ['https://ipfs.old.example'], + plebbitRpcClientsOptions: ['ws://old.example/key'], + pubsubHttpClientsOptions: ['https://pubsub.old.example'], + }, + } as Record, + rpcSettings: { + plebbitRpcSettings: { + plebbitOptions: { + dataPath: '/tmp/plebbit-data', + }, + }, + state: 'disconnected', + } as Record, + setAccountMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + setAccount: (account: unknown) => testState.setAccountMock(account), + useAccount: () => testState.account, + usePlebbitRpcSettings: () => testState.rpcSettings, +})); + +let alertSpy: ReturnType; +let container: HTMLDivElement; +let consoleLogSpy: ReturnType; +const originalLocation = window.location; +let reloadMock: ReturnType; +let root: Root; + +const loadComponent = async (isElectron = false) => { + vi.resetModules(); + (window as unknown as { electronApi?: { isElectron?: boolean } }).electronApi = isElectron ? { isElectron: true } : undefined; + return (await import('../advanced-settings')).default; +}; + +const renderSettings = async (isElectron = false) => { + const AdvancedSettings = await loadComponent(isElectron); + await act(async () => { + root.render(createElement(AdvancedSettings)); + }); +}; + +const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => { + await act(async () => { + const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +const clickButton = async (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); +}; + +describe('AdvancedSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.account = { + mediaIpfsGatewayUrl: 'https://media.old.example', + plebbitOptions: { + chainProviders: { + eth: { chainId: 1, urls: ['https://eth.old.example'] }, + sol: { chainId: 101, urls: ['https://sol.old.example'] }, + }, + httpRoutersOptions: ['https://router.old.example'], + ipfsGatewayUrls: ['https://ipfs.old.example'], + plebbitRpcClientsOptions: ['ws://old.example/key'], + pubsubHttpClientsOptions: ['https://pubsub.old.example'], + }, + }; + testState.rpcSettings = { + plebbitRpcSettings: { + plebbitOptions: { + dataPath: '/tmp/plebbit-data', + }, + }, + state: 'disconnected', + }; + testState.setAccountMock.mockReset().mockResolvedValue(undefined); + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + reload: reloadMock, + }, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => root.unmount()); + } + container?.remove(); + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + alertSpy?.mockRestore(); + consoleLogSpy?.mockRestore(); + }); + + it('saves trimmed gateway, provider, router, rpc, and chain settings', async () => { + await renderSettings(true); + + const textareas = container.querySelectorAll('textarea'); + const textInputs = container.querySelectorAll('input[type="text"]'); + expect(textareas.length).toBe(5); + expect(textInputs.length).toBe(3); + + await dispatchInput(textareas[0], ' https://ipfs.one.example \n\nhttps://ipfs.two.example '); + await dispatchInput(textInputs[0], ' https://media.new.example '); + await dispatchInput(textareas[1], ' https://pubsub.one.example \n'); + await dispatchInput(textareas[2], ' https://router.one.example \n'); + await dispatchInput(textareas[3], ' https://eth.one.example \n'); + await dispatchInput(textareas[4], ' https://sol.one.example \n'); + await dispatchInput(textInputs[1], ' ws://127.0.0.1:9138/secret '); + await dispatchInput(textInputs[2], ' /tmp/next-plebbit '); + await clickButton('save_options'); + + expect(testState.setAccountMock).toHaveBeenCalledWith({ + mediaIpfsGatewayUrl: 'https://media.new.example', + plebbitOptions: { + chainProviders: { + eth: { chainId: 1, urls: ['https://eth.one.example'] }, + sol: { chainId: 101, urls: ['https://sol.one.example'] }, + }, + dataPath: '/tmp/next-plebbit', + httpRoutersOptions: ['https://router.one.example'], + ipfsGatewayUrls: ['https://ipfs.one.example', 'https://ipfs.two.example'], + plebbitRpcClientsOptions: ['ws://127.0.0.1:9138/secret'], + pubsubHttpClientsOptions: ['https://pubsub.one.example'], + }, + }); + expect(alertSpy).toHaveBeenCalledWith('Options saved, reloading...'); + expect(reloadMock).toHaveBeenCalledOnce(); + }); + + it('disables remote-managed textareas and shows the electron data path when RPC is connected', async () => { + testState.rpcSettings = { + plebbitRpcSettings: { + plebbitOptions: { + dataPath: '/tmp/connected-node', + }, + }, + state: 'connected', + }; + + await renderSettings(true); + + const textareas = container.querySelectorAll('textarea'); + const textInputs = container.querySelectorAll('input[type="text"]'); + expect(textareas[0]?.disabled).toBe(true); + expect(textareas[1]?.disabled).toBe(true); + expect(textareas[2]?.disabled).toBe(true); + expect(textInputs[0]?.disabled).toBe(true); + expect(textInputs[2]?.disabled).toBe(false); + expect(textInputs[2]?.value).toBe('/tmp/connected-node'); + }); + + it('toggles the node rpc instructions panel', async () => { + await renderSettings(false); + + expect(container.textContent).not.toContain('secret auth key'); + await clickButton('?'); + expect(container.textContent).toContain('secret auth key'); + await clickButton('X'); + expect(container.textContent).not.toContain('secret auth key'); + }); + + it('alerts with the error message when saving fails with an Error instance', async () => { + testState.setAccountMock.mockRejectedValueOnce(new Error('boom')); + + await renderSettings(false); + await clickButton('save_options'); + + expect(alertSpy).toHaveBeenCalledWith('Error saving options: boom'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); + + it('alerts with a generic message when saving fails with a non-error value', async () => { + testState.setAccountMock.mockRejectedValueOnce('bad'); + + await renderSettings(false); + await clickButton('save_options'); + + expect(alertSpy).toHaveBeenCalledWith('Error'); + }); +}); diff --git a/src/hooks/__tests__/use-fetch-gif-first-frame.test.tsx b/src/hooks/__tests__/use-fetch-gif-first-frame.test.tsx new file mode 100644 index 00000000..18d63775 --- /dev/null +++ b/src/hooks/__tests__/use-fetch-gif-first-frame.test.tsx @@ -0,0 +1,262 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + cacheGetItemMock: vi.fn(), + cacheSetItemMock: vi.fn(), + fetchMock: vi.fn(), + fileBuffer: new Uint8Array([71, 73, 70]).buffer, + imageShouldFail: false, + nextBlobId: 0, + toBlobReturnsNull: false, + xhrCalls: [] as string[], + xhrResponses: new Map(), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ + default: { + createInstance: () => ({ + getItem: testState.cacheGetItemMock, + setItem: testState.cacheSetItemMock, + }), + }, +})); + +type HookResult = { + frameUrl: string | null; + status: 'failed' | 'idle' | 'loading' | 'ready'; +}; + +let container: HTMLDivElement; +let consoleErrorSpy: ReturnType; +let createObjectUrlSpy: ReturnType; +let revokeObjectUrlSpy: ReturnType; +let root: Root; + +class MockXMLHttpRequest { + onloadend: (() => void) | null = null; + response: ArrayBuffer | undefined; + responseType = ''; + status = 0; + statusText = ''; + private url = ''; + + open(_method: string, url: string) { + this.url = url; + } + + send() { + testState.xhrCalls.push(this.url); + const response = testState.xhrResponses.get(this.url) ?? { + response: undefined, + status: 500, + statusText: 'Failed', + }; + this.response = response.response; + this.status = response.status; + this.statusText = response.statusText; + queueMicrotask(() => this.onloadend?.()); + } +} + +class MockFileReader { + onload: (() => void) | null = null; + result: ArrayBuffer | null = null; + + readAsArrayBuffer() { + this.result = testState.fileBuffer; + queueMicrotask(() => this.onload?.()); + } +} + +class MockImage { + height = 120; + onerror: (() => void) | null = null; + onload: (() => void) | null = null; + width = 160; + + set src(_value: string) { + queueMicrotask(() => { + if (testState.imageShouldFail) { + this.onerror?.(); + } else { + this.onload?.(); + } + }); + } +} + +const flushEffects = async (count = 6) => { + for (let index = 0; index < count; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const dispatchRender = async (rootToRender: Root, element: React.ReactElement) => { + await act(async () => { + rootToRender.render(element); + }); + await flushEffects(); +}; + +const renderHook = async (source: unknown) => { + vi.resetModules(); + const { default: useFetchGifFirstFrame } = await import('../use-fetch-gif-first-frame'); + let latestState: HookResult = { frameUrl: null, status: 'idle' }; + + const HookHarness = ({ value }: { value: unknown }) => { + latestState = useFetchGifFirstFrame(value as any); + return createElement('div', { + 'data-frame-url': latestState.frameUrl ?? '', + 'data-status': latestState.status, + }); + }; + + await dispatchRender(root, createElement(HookHarness, { value: source })); + return { + getState: () => latestState, + HookHarness, + useFetchGifFirstFrame, + }; +}; + +describe('useFetchGifFirstFrame', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.cacheGetItemMock.mockReset(); + testState.cacheSetItemMock.mockReset(); + testState.fetchMock.mockReset(); + testState.fileBuffer = new Uint8Array([71, 73, 70]).buffer; + testState.imageShouldFail = false; + testState.nextBlobId = 0; + testState.toBlobReturnsNull = false; + testState.xhrCalls = []; + testState.xhrResponses = new Map(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.stubGlobal('fetch', testState.fetchMock); + vi.stubGlobal('FileReader', MockFileReader); + vi.stubGlobal('Image', MockImage as unknown as typeof Image); + vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest as unknown as typeof XMLHttpRequest); + createObjectUrlSpy = vi.fn(() => `blob:generated-${++testState.nextBlobId}`); + revokeObjectUrlSpy = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrlSpy, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectUrlSpy, + }); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => { + if (tagName === 'canvas') { + return { + getContext: () => ({ + drawImage: vi.fn(), + }), + height: 0, + toBlob: (callback: (blob: Blob | null) => void) => callback(testState.toBlobReturnsNull ? null : new Blob(['frame'])), + width: 0, + } as unknown as HTMLCanvasElement; + } + return originalCreateElement(tagName, options); + }) as typeof document.createElement); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + consoleErrorSpy.mockRestore(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('returns an idle state when no url is provided', async () => { + const { getState } = await renderHook(undefined); + expect(getState()).toEqual({ + frameUrl: null, + status: 'idle', + }); + }); + + it('reuses a cached frame url when the cached asset is still fetchable', async () => { + testState.cacheGetItemMock.mockResolvedValueOnce('blob:cached-frame'); + testState.fetchMock.mockResolvedValueOnce({ ok: true }); + + const { getState } = await renderHook('https://cdn.example/animated.gif'); + + expect(getState()).toEqual({ + frameUrl: 'blob:cached-frame', + status: 'ready', + }); + expect(testState.fetchMock).toHaveBeenCalledWith('blob:cached-frame'); + expect(testState.xhrCalls).toEqual([]); + expect(testState.cacheSetItemMock).not.toHaveBeenCalled(); + }); + + it('fetches, parses, and caches a generated frame when no usable cache entry exists', async () => { + testState.cacheGetItemMock.mockResolvedValueOnce(null); + testState.xhrResponses.set('https://cdn.example/animated.gif', { + response: new Uint8Array([71, 73, 70]).buffer, + status: 200, + statusText: 'OK', + }); + + const { getState } = await renderHook('https://cdn.example/animated.gif'); + + expect(getState().status).toBe('ready'); + expect(getState().frameUrl).toBe('blob:generated-2'); + expect(testState.xhrCalls).toEqual(['https://cdn.example/animated.gif']); + expect(testState.cacheSetItemMock).toHaveBeenCalledWith('https://cdn.example/animated.gif', 'blob:generated-2'); + expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:generated-1'); + }); + + it('reads File inputs through FileReader and caches the result', async () => { + testState.cacheGetItemMock.mockResolvedValueOnce(null); + const source = new File(['gif-bytes'], 'reply.gif', { type: 'image/gif' }); + + const { getState } = await renderHook(source); + + expect(getState().status).toBe('ready'); + expect(getState().frameUrl).toBe('blob:generated-2'); + expect(testState.xhrCalls).toEqual([]); + expect(testState.cacheSetItemMock).toHaveBeenCalledWith(source, 'blob:generated-2'); + }); + + it('marks a failed url and short-circuits retries for the same source', async () => { + testState.cacheGetItemMock.mockResolvedValue(null); + testState.xhrResponses.set('https://cdn.example/broken.gif', { + response: undefined, + status: 500, + statusText: 'Broken', + }); + + const { HookHarness, getState } = await renderHook('https://cdn.example/broken.gif'); + expect(getState()).toEqual({ + frameUrl: null, + status: 'failed', + }); + expect(testState.xhrCalls).toEqual(['https://cdn.example/broken.gif']); + + const retryRootContainer = document.createElement('div'); + document.body.appendChild(retryRootContainer); + const retryRoot = createRoot(retryRootContainer); + + await dispatchRender(retryRoot, createElement(HookHarness, { value: 'https://cdn.example/broken.gif' })); + expect(testState.xhrCalls).toEqual(['https://cdn.example/broken.gif']); + + act(() => retryRoot.unmount()); + retryRootContainer.remove(); + }); +});