test: cover edit, challenge, advanced settings, and gif preview flows

This commit is contained in:
plebeius
2026-03-08 16:48:01 +08:00
parent 90a01057f2
commit 2b32866b6b
4 changed files with 1144 additions and 0 deletions
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
abandonCurrentChallengeMock: vi.fn().mockResolvedValue(undefined),
account: {
author: {
address: '0xabc123',
},
} as Record<string, any>,
challenges: [] as Array<{ challenge: any; id: number }>,
commentsByCid: {} as Record<string, { author?: { shortAddress?: string } }>,
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<string, unknown>) => {
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<typeof import('react')>('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<typeof vi.spyOn>;
let container: HTMLDivElement;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let postMessageMock: ReturnType<typeof vi.fn>;
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<string, unknown>) => ({
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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);
});
});
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {
author: {
address: '0xmod',
displayName: 'Moderator',
shortAddress: '0xmod',
},
signer: {
address: '0xauthor',
},
} as Record<string, any>,
addChallengeMock: vi.fn(),
authorOptions: undefined as Record<string, any> | undefined,
isMobile: false,
modOptions: undefined as Record<string, any> | 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<number, React.ReactElement>; 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<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) => props || {},
}),
useRole: () => ({}),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
usePublishCommentEdit: (options: Record<string, any>) => {
testState.authorOptions = options;
return { publishCommentEdit: testState.publishAuthorEditMock };
},
usePublishCommentModeration: (options: Record<string, any>) => {
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<typeof vi.spyOn>;
let confirmSpy: ReturnType<typeof vi.spyOn>;
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<string, any>;
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<HTMLInputElement>(`#${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<HTMLInputElement>('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<HTMLInputElement>('[data-testid="ban-duration-input"]');
const reasonInput = container.querySelector<HTMLInputElement>('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();
});
});
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
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<string, any>,
rpcSettings: {
plebbitRpcSettings: {
plebbitOptions: {
dataPath: '/tmp/plebbit-data',
},
},
state: 'disconnected',
} as Record<string, any>,
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<typeof vi.spyOn>;
let container: HTMLDivElement;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
const originalLocation = window.location;
let reloadMock: ReturnType<typeof vi.fn>;
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<HTMLTextAreaElement>('textarea');
const textInputs = container.querySelectorAll<HTMLInputElement>('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<HTMLTextAreaElement>('textarea');
const textInputs = container.querySelectorAll<HTMLInputElement>('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');
});
});
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
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<string, { response?: ArrayBuffer; status: number; statusText: string }>(),
}));
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<typeof vi.spyOn>;
let createObjectUrlSpy: ReturnType<typeof vi.fn>;
let revokeObjectUrlSpy: ReturnType<typeof vi.fn>;
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();
});
});