diff --git a/electron/media-upload-automation.js b/electron/media-upload-automation.js index 4d64aaac..9f6a7382 100644 --- a/electron/media-upload-automation.js +++ b/electron/media-upload-automation.js @@ -2,14 +2,22 @@ * Main-process automation for media upload via provider web UIs. * Uses a hidden BrowserWindow + CDP (DOM.setFileInputFiles) for non-interactive uploads. * Fail-fast on blocked indicators (captcha/login). No interactive fallback. + * + * Diagnostics (parity with Android): selector match/timeout info included in errors + * for debugging. Poll interval 500ms, timeout per recipe matches Android where + * providers overlap (imgur/postimages: 45s). */ import { BrowserWindow } from 'electron'; import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js'; +/** Poll interval (ms) – parity with Android MediaUploadRecipes.POLL_INTERVAL_MS */ +const POLL_INTERVAL_MS = 500; + /** File extensions that denote direct media URLs (mirrors src/lib/media-hosting/direct-url.ts) */ const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv']; -function isDirectMediaUrl(url) { +/** Returns true if the URL appears to point to a direct media file. Exported for tests. */ +export function isDirectMediaUrl(url) { try { const normalized = url.split('?')[0].toLowerCase(); return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext)); @@ -89,15 +97,14 @@ export async function automateUploadMedia(options) { const checkBlocked = async () => { for (const sel of recipe.blockedIndicators) { const nodeId = await queryOne(sel); - if (nodeId) { - return true; - } + if (nodeId) return sel; } - return false; + return null; }; - if (await checkBlocked()) { - throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider})`); + const blockedSel = await checkBlocked(); + if (blockedSel) { + throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider}), selector: ${blockedSel}`); } let fileInputNodeId = null; @@ -181,23 +188,24 @@ export async function automateUploadMedia(options) { return result?.value ?? null; }; - const pollIntervalMs = 500; const start = Date.now(); let url = null; while (Date.now() - start < recipe.timeoutMs) { - if (await checkBlocked()) { - throw new Error(`Provider blocked during upload: captcha or challenge (${provider})`); + const blockedDuring = await checkBlocked(); + if (blockedDuring) { + throw new Error(`Provider blocked during upload: captcha or challenge (${provider}), selector: ${blockedDuring}`); } url = await extractUrl(); if (url && isDirectMediaUrl(url)) { break; } url = null; - await new Promise((r) => setTimeout(r, pollIntervalMs)); + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } + const elapsed = Date.now() - start; if (!url || !isDirectMediaUrl(url)) { - throw new Error(`Upload timeout or no direct URL extracted for ${provider} (${recipe.timeoutMs}ms)`); + throw new Error(`Upload timeout or no direct URL extracted for ${provider} (elapsed: ${elapsed}ms, timeout: ${recipe.timeoutMs}ms)`); } return { url, provider }; diff --git a/electron/media-upload-automation.test.js b/electron/media-upload-automation.test.js new file mode 100644 index 00000000..ca854593 --- /dev/null +++ b/electron/media-upload-automation.test.js @@ -0,0 +1,57 @@ +/** + * Unit tests for media-upload-automation (Electron). + * Covers isDirectMediaUrl (URL extraction guard) and recipe usage. + */ +import { describe, expect, it } from 'vitest'; +import { isDirectMediaUrl } from './media-upload-automation.js'; +import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js'; + +describe('media-upload-automation', () => { + 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('strips query strings 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); + }); + + 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('https://postimages.org')).toBe(false); + expect(isDirectMediaUrl('')).toBe(false); + }); + + it('returns false for invalid input', () => { + expect(isDirectMediaUrl(null)).toBe(false); + expect(isDirectMediaUrl(undefined)).toBe(false); + }); + }); +}); + +describe('media-upload-automation + recipes integration', () => { + it('imgur and postimages success extractors target direct-media domains', () => { + const imgurSelectors = MEDIA_UPLOAD_RECIPES.imgur.successExtractor.selectorCandidates; + expect(imgurSelectors.some((s) => s.includes('i.imgur.com'))).toBe(true); + + const postimagesSelectors = MEDIA_UPLOAD_RECIPES.postimages.successExtractor.selectorCandidates; + expect(postimagesSelectors.some((s) => s.includes('postimg') || s.includes('i.postimg'))).toBe(true); + }); + + it('all providers have fallback selector chains for file input and submit', () => { + for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { + expect(recipe.fileInputSelectorCandidates.length).toBeGreaterThanOrEqual(1); + expect(recipe.submitSelectorCandidates.length).toBeGreaterThanOrEqual(1); + expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/electron/media-upload-recipes.js b/electron/media-upload-recipes.js index 09eba180..4272c30f 100644 --- a/electron/media-upload-recipes.js +++ b/electron/media-upload-recipes.js @@ -3,6 +3,15 @@ * Each recipe defines selectors and behavior for DOM-based upload flows. * Selectors are candidate-based: try each until one matches. Fail fast on blocked indicators. * + * Android vs Electron differences (documented to prevent drift): + * - Android (MediaUploadRecipes.java): imgur/postimages only; no catbox (uses different flow). + * Uses WebChromeClient file chooser interception; trigger JS clicks file input. + * - Electron: catbox/imgur/postimages. Uses CDP DOM.setFileInputFiles + submit button click. + * Catbox: Electron-only; keep behavior unchanged. + * - Blocked/success selectors: reconciled with Android for imgur/postimages; Electron may add + * extra candidates (e.g. [data-captcha], .login-form) where DOM differs. + * - Timeouts: imgur/postimages 45s (parity); catbox 30s (Electron-only). + * * @typedef {Object} ProviderRecipe * @property {string} uploadUrl - Full URL of the provider's upload page * @property {readonly string[]} fileInputSelectorCandidates - CSS selectors for file input (first match wins) @@ -27,6 +36,7 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({ blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '.login-form', '#recaptcha']), timeoutMs: 30_000, }), + /* Reconciled with Android MediaUploadRecipes (imgur): file input, success extractors, blocked indicators. */ imgur: Object.freeze({ uploadUrl: 'https://imgur.com/upload', fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '[data-file-input]']), @@ -35,9 +45,11 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({ selectorCandidates: Object.freeze(['a[href*="i.imgur.com"]', 'input[value*="i.imgur.com"]', '[class*="copy-link"] input', '[data-link]']), attribute: 'href', }), - blockedIndicators: Object.freeze(['#challenge', '.captcha', '.g-recaptcha', '#recaptcha', '.signin', '.login']), + /* Android: .signin, .login. Electron adds: [data-captcha], .login-form for DOM variations. */ + blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']), timeoutMs: 45_000, }), + /* Reconciled with Android MediaUploadRecipes (postimages): file input, success extractors, blocked indicators. */ postimages: Object.freeze({ uploadUrl: 'https://postimages.org', fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '#uploadFile', '.fileinput']), @@ -50,3 +62,35 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({ timeoutMs: 45_000, }), }); + +/** + * Validates that every provider has required recipe fields: trigger (file input), success extractor, + * blocked indicators, and timeout. Throws if any provider is invalid. + */ +function validateRecipes() { + const required = ['fileInputSelectorCandidates', 'submitSelectorCandidates', 'successExtractor', 'blockedIndicators', 'timeoutMs']; + for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { + for (const key of required) { + if (!(key in recipe) || recipe[key] == null) { + throw new Error(`Recipe validation failed: ${provider} missing or null: ${key}`); + } + } + const ex = recipe.successExtractor; + if (!Array.isArray(ex?.selectorCandidates) || ex.selectorCandidates.length === 0 || !ex.attribute) { + throw new Error(`Recipe validation failed: ${provider} successExtractor must have non-empty selectorCandidates and attribute`); + } + if (!Array.isArray(recipe.fileInputSelectorCandidates) || recipe.fileInputSelectorCandidates.length === 0) { + throw new Error(`Recipe validation failed: ${provider} fileInputSelectorCandidates must be non-empty array`); + } + if (!Array.isArray(recipe.blockedIndicators) || recipe.blockedIndicators.length === 0) { + throw new Error(`Recipe validation failed: ${provider} blockedIndicators must be non-empty array`); + } + if (typeof recipe.timeoutMs !== 'number' || recipe.timeoutMs <= 0) { + throw new Error(`Recipe validation failed: ${provider} timeoutMs must be positive number`); + } + } +} + +validateRecipes(); + +export { validateRecipes }; diff --git a/electron/media-upload-recipes.test.js b/electron/media-upload-recipes.test.js new file mode 100644 index 00000000..b9281711 --- /dev/null +++ b/electron/media-upload-recipes.test.js @@ -0,0 +1,42 @@ +/** + * Tests for media-upload-recipes validation. + * Recipes are validated at module load; these tests verify the validation logic. + */ +import { describe, expect, it } from 'vitest'; +import { MEDIA_UPLOAD_RECIPES, validateRecipes } from './media-upload-recipes.js'; + +describe('media-upload-recipes', () => { + it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => { + expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox'); + expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur'); + expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('postimages'); + }); + + it('validateRecipes passes for current recipes', () => { + expect(() => validateRecipes()).not.toThrow(); + }); + + it('every provider has trigger, success, blocked selectors and timeout', () => { + for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { + expect(recipe.fileInputSelectorCandidates?.length).toBeGreaterThan(0); + expect(recipe.submitSelectorCandidates?.length).toBeGreaterThan(0); + expect(recipe.successExtractor?.selectorCandidates?.length).toBeGreaterThan(0); + expect(recipe.successExtractor?.attribute).toBeTruthy(); + expect(recipe.blockedIndicators?.length).toBeGreaterThan(0); + expect(typeof recipe.timeoutMs).toBe('number'); + expect(recipe.timeoutMs).toBeGreaterThan(0); + } + }); + + it('imgur and postimages have 45s timeout (parity with Android)', () => { + expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000); + expect(MEDIA_UPLOAD_RECIPES.postimages.timeoutMs).toBe(45_000); + }); + + it('selector fallback order: generic file input before provider-specific', () => { + for (const [, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { + const first = recipe.fileInputSelectorCandidates[0]; + expect(first).toMatch(/input\[type\s*=\s*["']?file["']?\]/i); + } + }); +});