test: expand directory and electron upload coverage

Added hook coverage for cached directory hydration, alias lookup, and fallback behavior, plus broader Electron automation tests around hidden BrowserWindow upload flows and failure cases. useDirectoriesState() and related fallback paths now stay synchronized when the shared cache is already populated but the GitHub refresh fails.
This commit is contained in:
plebeius
2026-03-08 13:04:51 +08:00
parent 0d34b477e4
commit eda5af7bf2
3 changed files with 474 additions and 11 deletions
+195 -5
View File
@@ -1,12 +1,98 @@
/**
* Unit tests for media-upload-automation (Electron).
* Covers isDirectMediaUrl (URL extraction guard) and recipe usage.
* Covers direct URL detection plus BrowserWindow/CDP automation flows.
*/
import { describe, expect, it } from 'vitest';
import { isDirectMediaUrl } from './media-upload-automation.js';
import { EventEmitter } from 'node:events';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const electronState = vi.hoisted(() => ({
createWindow: null,
}));
vi.mock('electron', () => ({
BrowserWindow: vi.fn(function BrowserWindow(options) {
if (!electronState.createWindow) {
throw new Error(`Missing BrowserWindow mock for ${JSON.stringify(options)}`);
}
return electronState.createWindow(options);
}),
}));
import { automateUploadMedia, isDirectMediaUrl } from './media-upload-automation.js';
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
const createFakeBrowserWindow = ({ attachError = null, loadError = null, getNodeId = () => 0, runtimeValues = [], missingBoxModel = false } = {}) => {
const emitter = new EventEmitter();
let attached = false;
let destroyed = false;
const sendCommand = vi.fn(async (method, params = {}) => {
switch (method) {
case 'DOM.enable':
case 'Page.enable':
case 'Input.enable':
return {};
case 'DOM.getDocument':
return { root: { nodeId: 1 } };
case 'DOM.querySelector':
return { nodeId: getNodeId(params.selector) || 0 };
case 'DOM.setFileInputFiles':
return {};
case 'DOM.getBoxModel':
if (missingBoxModel) {
return { model: null };
}
return { model: { content: [0, 0, 20, 0, 20, 20, 0, 20] } };
case 'Runtime.evaluate':
return { result: { value: runtimeValues.length > 0 ? runtimeValues.shift() : null } };
case 'Input.dispatchMouseEvent':
return {};
default:
throw new Error(`Unhandled CDP command in test: ${method}`);
}
});
const fakeWindow = {
loadURL: vi.fn((url) => {
queueMicrotask(() => {
if (loadError) {
emitter.emit('did-fail-load', {}, loadError.code ?? -1, loadError.description ?? 'load failed');
return;
}
emitter.emit('did-finish-load');
});
return url;
}),
destroy: vi.fn(() => {
destroyed = true;
}),
isDestroyed: vi.fn(() => destroyed),
webContents: Object.assign(emitter, {
debugger: {
attach: vi.fn(() => {
if (attachError) {
throw attachError;
}
attached = true;
}),
sendCommand,
detach: vi.fn(() => {
attached = false;
}),
isAttached: vi.fn(() => attached),
},
}),
};
return fakeWindow;
};
describe('media-upload-automation', () => {
beforeEach(() => {
vi.clearAllMocks();
electronState.createWindow = null;
});
describe('isDirectMediaUrl', () => {
it('returns true for image extensions', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true);
@@ -37,16 +123,120 @@ describe('media-upload-automation', () => {
expect(isDirectMediaUrl(undefined)).toBe(false);
});
});
describe('automateUploadMedia', () => {
it('rejects unknown providers before opening a window', async () => {
await expect(automateUploadMedia({ provider: 'unknown', filePath: '/tmp/file.png' })).rejects.toThrow('No automation recipe for provider: unknown');
});
it('uploads through a hidden BrowserWindow and returns a direct media URL', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgur;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => {
if (selector === recipe.fileInputSelectorCandidates[0]) return 10;
if (selector === recipe.submitSelectorCandidates[0]) return 20;
return 0;
},
runtimeValues: ['https://i.imgur.com/uploaded.png'],
});
electronState.createWindow = () => fakeWindow;
const result = await automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' });
expect(result).toEqual({ url: 'https://i.imgur.com/uploaded.png', provider: 'imgur' });
expect(fakeWindow.loadURL).toHaveBeenCalledWith(recipe.uploadUrl);
expect(fakeWindow.webContents.debugger.attach).toHaveBeenCalledWith('1.3');
expect(fakeWindow.webContents.debugger.sendCommand).toHaveBeenCalledWith('DOM.setFileInputFiles', {
nodeId: 10,
files: ['/tmp/upload.png'],
});
expect(fakeWindow.webContents.debugger.sendCommand.mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent')).toHaveLength(2);
expect(fakeWindow.webContents.debugger.detach).toHaveBeenCalledOnce();
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
it('fails fast when a blocked indicator is present before upload begins', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgur;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => (selector === recipe.blockedIndicators[0] ? 99 : 0),
});
electronState.createWindow = () => fakeWindow;
await expect(automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' })).rejects.toThrow(
`Provider blocked: captcha, login, or challenge detected (imgur), selector: ${recipe.blockedIndicators[0]}`,
);
expect(fakeWindow.webContents.debugger.detach).toHaveBeenCalledOnce();
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
it('errors when no file input can be found', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgur;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => (selector === recipe.submitSelectorCandidates[0] ? 20 : 0),
});
electronState.createWindow = () => fakeWindow;
await expect(automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' })).rejects.toThrow(
`No file input found for imgur. Tried: ${recipe.fileInputSelectorCandidates.join(', ')}`,
);
expect(fakeWindow.webContents.debugger.detach).toHaveBeenCalledOnce();
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
it('bubbles page-load failures and still destroys the hidden window', async () => {
const fakeWindow = createFakeBrowserWindow({
loadError: { code: -3, description: 'aborted' },
});
electronState.createWindow = () => fakeWindow;
await expect(automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' })).rejects.toThrow('Page load failed: -3 aborted');
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
it('surfaces debugger attach failures and still destroys the hidden window', async () => {
const fakeWindow = createFakeBrowserWindow({
attachError: new Error('attach failed'),
});
electronState.createWindow = () => fakeWindow;
await expect(automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' })).rejects.toThrow('attach failed');
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
it('surfaces click failures when the submit button box model is unavailable', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgur;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => {
if (selector === recipe.fileInputSelectorCandidates[0]) return 10;
if (selector === recipe.submitSelectorCandidates[0]) return 20;
return 0;
},
missingBoxModel: true,
});
electronState.createWindow = () => fakeWindow;
await expect(automateUploadMedia({ provider: 'imgur', filePath: '/tmp/upload.png' })).rejects.toThrow(
'Cannot click node: box model unavailable (element may be hidden or zero-size)',
);
expect(fakeWindow.webContents.debugger.detach).toHaveBeenCalledOnce();
expect(fakeWindow.destroy).toHaveBeenCalledOnce();
});
});
});
describe('media-upload-automation + recipes integration', () => {
it('imgur success extractor targets direct-media domain', () => {
const imgurSelectors = MEDIA_UPLOAD_RECIPES.imgur.successExtractor.selectorCandidates;
expect(imgurSelectors.some((s) => s.includes('i.imgur.com'))).toBe(true);
expect(imgurSelectors.some((selector) => selector.includes('i.imgur.com'))).toBe(true);
});
it('all providers have fallback selector chains for file input and submit', () => {
for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
for (const recipe of Object.values(MEDIA_UPLOAD_RECIPES)) {
expect(recipe.fileInputSelectorCandidates.length).toBeGreaterThanOrEqual(1);
expect(recipe.submitSelectorCandidates.length).toBeGreaterThanOrEqual(1);
expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1);
@@ -0,0 +1,245 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetDirectoriesModuleStateForTests,
findDirectoryByAddress,
normalizeBoardAddress,
useDirectories,
useDirectoriesMetadata,
useDirectoriesState,
useDirectoryAddresses,
useDirectoryByAddress,
type DirectoriesData,
} from '../use-directories';
(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 LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
type Snapshot = {
directories: ReturnType<typeof useDirectories>;
state: ReturnType<typeof useDirectoriesState>;
addresses: ReturnType<typeof useDirectoryAddresses>;
directory: ReturnType<typeof useDirectoryByAddress>;
metadata: ReturnType<typeof useDirectoriesMetadata>;
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
type ConsoleWarnCall = Parameters<typeof console.warn>;
let latestSnapshot: Snapshot | null = null;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
const HookHarness = ({ address = 'music-posting.eth' }: { address?: string }) => {
const directories = useDirectories();
const state = useDirectoriesState();
const addresses = useDirectoryAddresses();
const directory = useDirectoryByAddress(address);
const metadata = useDirectoriesMetadata();
React.useLayoutEffect(() => {
latestSnapshot = {
directories,
state,
addresses,
directory,
metadata,
};
}, [addresses, directory, directories, metadata, state]);
return null;
};
const createDeferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
};
const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
ok,
status,
json: vi.fn().mockResolvedValue(body),
});
const flushEffects = async (count = 4) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
};
const renderHarness = (address?: string) => {
act(() => {
root.render(createElement(HookHarness, { address }));
});
};
describe('use-directories', () => {
beforeEach(() => {
vi.clearAllMocks();
latestSnapshot = null;
__resetDirectoriesModuleStateForTests();
localStorage.clear();
fetchMock = vi.fn();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.stubGlobal('fetch', fetchMock);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
warnSpy.mockRestore();
vi.unstubAllGlobals();
__resetDirectoriesModuleStateForTests();
});
it('normalizes aliases and finds matching directories by exact or alias address', () => {
const communities = [
{ address: 'music-posting.bso', title: '/mu/ - Music' },
{ address: 'business.eth', title: '/biz/ - Business & Finance' },
];
expect(normalizeBoardAddress('music-posting.eth')).toBe('music-posting');
expect(normalizeBoardAddress('business.bso')).toBe('business');
expect(normalizeBoardAddress('business.xyz')).toBe('business.xyz');
expect(findDirectoryByAddress(communities, 'music-posting.bso')?.address).toBe('music-posting.bso');
expect(findDirectoryByAddress(communities, 'music-posting.eth')?.address).toBe('music-posting.bso');
expect(findDirectoryByAddress(communities, undefined)).toBeUndefined();
});
it('hydrates from localStorage first, then refreshes from GitHub with normalized and deduped data', async () => {
const cachedData: DirectoriesData = {
title: 'Cached directories',
description: 'cached description',
createdAt: 1,
updatedAt: 2,
communities: [
{ address: 'music-posting.bso', title: '/mu/ - Cached Music', nsfw: false },
{ address: 'flash.bso', title: '/f/ - Flash', nsfw: true },
],
};
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
{
communityAddress: 'music-posting.bso',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25, nested: { ignore: true } },
},
{
communityAddress: 'flash.bso',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
},
{
communityAddress: 'flash.bso',
title: '/f/ - Duplicate Flash',
directoryCode: 'f',
},
],
};
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(cachedData));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
fetchMock.mockReturnValueOnce(pendingFetch.promise);
renderHarness();
await flushEffects();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata).toEqual({
title: 'Cached directories',
description: 'cached description',
createdAt: 1,
updatedAt: 2,
});
pendingFetch.resolve(createFetchResponse(remotePayload));
await flushEffects();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.directories).toEqual([
{
address: 'music-posting.bso',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
nsfw: false,
},
{
address: 'flash.bso',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
nsfw: true,
},
]);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata).toEqual({
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
});
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('Fresh directories');
expect(persisted.communities).toHaveLength(2);
});
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ title: 'broken cache' }));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
fetchMock.mockRejectedValueOnce(new Error('network down'));
renderHarness('unknown.eth');
await flushEffects(8);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(LOCALSTORAGE_KEY)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE_TIMESTAMP_KEY)).toBeNull();
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Invalid directories cache format'))).toBe(true);
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directories'))).toBe(true);
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.directories.length).toBeGreaterThan(0);
expect(latestSnapshot?.addresses.length).toBeGreaterThan(0);
expect(latestSnapshot?.directory).toBeUndefined();
expect(latestSnapshot?.metadata).not.toBeNull();
});
});
+34 -6
View File
@@ -52,6 +52,14 @@ let cacheMetadata: DirectoriesMetadata | null = null;
let inFlightGitHubFetch: Promise<DirectoriesData> | null = null;
const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
// Exposed for deterministic unit tests around module-level cache state.
export const __resetDirectoriesModuleStateForTests = () => {
cacheCommunities = null;
cacheMetadata = null;
inFlightGitHubFetch = null;
fallbackDirectoriesData = null;
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
@@ -319,8 +327,16 @@ export const useDirectories = () => {
hydrateCommunities(directories);
} catch (e) {
console.warn('Failed to fetch directories from GitHub:', e);
// Only fall back if we don't already have memory/localStorage data
if (!cacheCommunities) {
// Keep each hook instance in sync even if a sibling hook populated the module cache first.
if (cacheCommunities) {
if (isMounted) {
setState({
communities: cacheCommunities,
loading: false,
error: null,
});
}
} else {
hydrateCommunities(getFallbackDirectoriesData());
}
}
@@ -379,8 +395,16 @@ export const useDirectoriesState = () => {
hydrateCommunities(directories);
} catch (e) {
console.warn('Failed to fetch directories from GitHub:', e);
// Only fall back if we don't already have memory/localStorage data
if (!cacheCommunities) {
// Keep each hook instance in sync even if a sibling hook populated the module cache first.
if (cacheCommunities) {
if (isMounted) {
setState({
communities: cacheCommunities,
loading: false,
error: null,
});
}
} else {
hydrateCommunities(getFallbackDirectoriesData());
}
}
@@ -439,8 +463,12 @@ export const useDirectoriesMetadata = () => {
hydrateMetadata(directories);
} catch (e) {
console.warn('Failed to fetch directory metadata from GitHub:', e);
// Only fall back if we don't already have memory/localStorage data
if (!cacheMetadata) {
// Keep each hook instance in sync even if a sibling hook populated the module cache first.
if (cacheMetadata) {
if (isMounted) {
setMetadata(cacheMetadata);
}
} else {
hydrateMetadata(getFallbackDirectoriesData());
}
}