Files
5chan/electron/media-upload-automation.test.js
T

278 lines
11 KiB
JavaScript

/**
* Unit tests for media-upload-automation (Electron).
* Covers direct URL detection plus BrowserWindow/CDP automation flows.
*/
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);
expect(isDirectMediaUrl('https://i.imgur.com/abc.png')).toBe(true);
expect(isDirectMediaUrl('https://i.postimg.cc/xyz.webp')).toBe(true);
});
it('returns true for video extensions', () => {
expect(isDirectMediaUrl('https://example.com/video.webm')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
});
it('returns true for Flash movie extensions', () => {
expect(isDirectMediaUrl('https://example.com/movie.swf')).toBe(true);
expect(isDirectMediaUrl('https://example.com/movie.SWF?download=1')).toBe(true);
});
it('strips query strings and fragments before checking', () => {
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
expect(isDirectMediaUrl('https://i.imgur.com/abc.png#fragment')).toBe(true);
expect(isDirectMediaUrl('https://i.postimg.cc/xyz.webp#')).toBe(true);
});
it('returns false for non-direct URLs (guards against non-media pages)', () => {
expect(isDirectMediaUrl('https://imgur.com/abc123')).toBe(false);
expect(isDirectMediaUrl('https://imgur.com/upload')).toBe(false);
expect(isDirectMediaUrl('')).toBe(false);
});
it('returns false for invalid input', () => {
expect(isDirectMediaUrl(null)).toBe(false);
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('uploads to ImgBB through the hidden BrowserWindow recipe', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgbb;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => {
if (selector === recipe.fileInputSelectorCandidates[0]) return 10;
if (selector === recipe.submitSelectorCandidates[0]) return 20;
return 0;
},
runtimeValues: [true, 'https://i.ibb.co/example/uploaded.png'],
});
electronState.createWindow = () => fakeWindow;
const result = await automateUploadMedia({ provider: 'imgbb', filePath: '/tmp/upload.png' });
expect(result).toEqual({ url: 'https://i.ibb.co/example/uploaded.png', provider: 'imgbb' });
expect(fakeWindow.loadURL).toHaveBeenCalledWith(recipe.uploadUrl);
expect(fakeWindow.webContents.debugger.sendCommand).toHaveBeenCalledWith('DOM.setFileInputFiles', {
nodeId: 10,
files: ['/tmp/upload.png'],
});
});
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((selector) => selector.includes('i.imgur.com'))).toBe(true);
});
it('all providers have fallback selector chains for file input and submit', () => {
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);
}
});
it('imgbb success extractor targets direct-media domain', () => {
const imgbbSelectors = MEDIA_UPLOAD_RECIPES.imgbb.successExtractor.selectorCandidates;
expect(imgbbSelectors.some((selector) => selector.includes('i.ibb.co') || selector.includes('html-embed-medium'))).toBe(true);
});
});