import { randomUUID } from 'node:crypto' import { writeFile, unlink, stat } from 'node:fs/promises' import path from 'node:path' import { execa } from 'execa' import { preprocess } from '../lib/imagemagick.js' import { SCHEMAS, buildArgs, runChafa, runJp2a, runAsciiImageConverter, runImgToTxt } from '../lib/converters.js' const RUNNERS = { chafa: runChafa, jp2a: runJp2a, 'ascii-image-converter': runAsciiImageConverter, img2txt: runImgToTxt, } const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024 const BINARIES = [ { key: 'chafa', cmd: 'chafa', args: ['--version'], src: 'stdout' }, { key: 'jp2a', cmd: 'jp2a', args: ['--version'], src: 'both' }, { key: 'ascii-image-converter', cmd: 'ascii-image-converter', args: ['--version'], src: 'stdout' }, { key: 'img2txt', cmd: 'img2txt', args: ['--help'], src: 'both' }, { key: 'imagemagick', cmd: 'magick', args: ['--version'], src: 'stdout' }, ] function validateUrl(raw) { let url try { url = new URL(raw) } catch { throw new Error('Invalid URL') } if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only http/https URLs are allowed') return url.href } async function downloadToFile(url, destPath) { const res = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { 'User-Agent': 'ASCIInator/1.0 (test API; +https://github.com/asciinator)' }, }) if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`) const buf = await res.arrayBuffer() if (buf.byteLength > MAX_DOWNLOAD_BYTES) throw new Error('Image exceeds 10 MB limit') await writeFile(destPath, Buffer.from(buf)) } function parseVersion(stdout, stderr) { const text = (stdout + '\n' + stderr).trim() // Match semantic version: optional 'v' prefix, e.g. 1.2.3, v1.2.3, 0.99.19 const match = text.match(/v?(\d+\.\d+[\d.]*)/) return match ? match[1] : null } async function probeImage(filePath) { const result = await execa('magick', ['identify', '-format', '%w %h %m', filePath], { reject: false }) const [w, h, fmt] = result.stdout.trim().split(' ') const { size } = await stat(filePath) return { width: parseInt(w) || null, height: parseInt(h) || null, format: fmt || null, size_bytes: size } } function schemaToFlagList(tool) { const schema = SCHEMAS[tool] if (!schema) return null const entries = Object.entries(schema) // Chafa uses --size WxH internally but the API accepts width + height separately const normalized = tool === 'chafa' ? entries.flatMap(([name, def]) => name === 'size' ? [['width', { type: 'value' }], ['height', { type: 'value' }]] : [[name, def]] ) : entries return normalized.map(([name, def]) => { let type = 'string' if (def.type === 'bool') type = 'boolean' else if (def.valid) type = 'enum' else if (['width', 'height', 'work'].includes(name)) type = 'integer' else if (['threshold', 'font-ratio', 'gamma', 'red', 'green', 'blue', 'dither-intensity', 'edge-threshold'].includes(name)) type = 'float' const entry = { name, type, cliFlag: `--${name}` } if (def.valid) entry.options = def.valid return entry }) } export async function testRoutes(fastify) { // GET /test/health fastify.get('/health', async (_req, reply) => { const binaries = {} await Promise.all(BINARIES.map(async ({ key, cmd, args }) => { try { const result = await execa(cmd, args, { reject: false, timeout: 5000 }) const version = parseVersion(result.stdout, result.stderr) binaries[key] = { available: true, version } } catch { binaries[key] = { available: false, version: null } } })) return reply.send({ status: 'ok', binaries, server: 'fastify', timestamp: new Date().toISOString() }) }) // POST /test/convert fastify.post('/convert', async (req, reply) => { const { imageUrl, tool, flags = {} } = req.body ?? {} if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' }) if (!tool) return reply.code(400).send({ error: 'tool required' }) if (!RUNNERS[tool]) return reply.code(400).send({ error: `Unknown tool: ${tool}` }) let validUrl try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) } const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg' const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}` const format = tool === 'jp2a' ? 'jpg' : 'png' let tempProcessed = null const t0 = Date.now() try { await downloadToFile(validUrl, tempInput) const t1 = Date.now() tempProcessed = await preprocess(tempInput, format) const t2 = Date.now() const result = await RUNNERS[tool](tempProcessed, flags) const t3 = Date.now() return reply.send({ success: true, tool, flags, output: result.stdout, stderr: result.stderr, exitCode: 0, timings: { download_ms: t1 - t0, imagemagick_ms: t2 - t1, conversion_ms: t3 - t2, total_ms: t3 - t0 }, }) } catch (err) { return reply.code(422).send({ success: false, tool, flags, output: null, stderr: err.stderr || err.message, exitCode: err.exitCode ?? 1, timings: { total_ms: Date.now() - t0 }, }) } finally { await unlink(tempInput).catch(() => {}) if (tempProcessed) await unlink(tempProcessed).catch(() => {}) } }) // GET /test/flags/:tool fastify.get('/flags/:tool', async (req, reply) => { const { tool } = req.params const flags = schemaToFlagList(tool) if (!flags) return reply.code(404).send({ error: `Unknown tool: ${tool}` }) return reply.send({ tool, flags }) }) // POST /test/imagemagick fastify.post('/imagemagick', async (req, reply) => { const { imageUrl } = req.body ?? {} if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' }) let validUrl try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) } const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg' const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}` let tempProcessed = null const t0 = Date.now() try { await downloadToFile(validUrl, tempInput) const inputMeta = await probeImage(tempInput) const t1 = Date.now() tempProcessed = await preprocess(tempInput, 'png') const t2 = Date.now() const outputMeta = await probeImage(tempProcessed) return reply.send({ success: true, input: inputMeta, output: outputMeta, commands_run: [`magick input${ext} -strip -auto-orient -resize 2000x2000> output.png`], duration_ms: t2 - t1, }) } catch (err) { return reply.code(500).send({ success: false, error: err.message }) } finally { await unlink(tempInput).catch(() => {}) if (tempProcessed) await unlink(tempProcessed).catch(() => {}) } }) }