import assert from "node:assert/strict" import test, { TestContext } from "node:test" import os from "node:os" import path from "node:path" import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import { listScanners, runScan } from "../src/scan/sane.js" import { commandExists, runCommand } from "../src/commands.js" import { AgentConfig, ScanJob } from "../src/types.js" const fakeScanner = `#!/usr/bin/env python3 import json, os, sys, time from pathlib import Path from PIL import Image args = sys.argv[1:] Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args)) if args == ['-L']: time.sleep(float(os.environ.get('SCAN_TEST_DISCOVERY_DELAY', '0'))) print("device " + chr(96) + "fujitsu:ScanSnap iX1600:test' is a FUJITSU scanner") print("device " + chr(96) + "airscan:test' is a network scanner") sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '0'))) batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None) single = args[args.index('--output-file') + 1] if '--output-file' in args else None count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1 for index in range(1, count + 1): target = batch % index if batch else single Image.new('RGB', (100 + index, 200 + index), (index * 10, 20, 30)).save(target) if batch and os.environ.get('SCAN_TEST_EMPTY_FINAL') == '1': Path(batch % (count + 1)).touch() if os.environ.get('SCAN_TEST_CORRUPT') == '1': Path(batch % 2 if batch else single).write_bytes(b'not a PNG') if os.environ.get('SCAN_TEST_EMPTY_MIDDLE') == '1': Path(batch % 2).write_bytes(b'') sys.stderr.write('simulated scanner status') sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '7' if batch else '0'))) ` const setup = async (t: TestContext) => { const dir = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-test-")) const oldEnv = { ...process.env } t.after(async () => { for (const key of Object.keys(process.env)) if (!(key in oldEnv)) delete process.env[key] Object.assign(process.env, oldEnv) await rm(dir, { recursive: true, force: true }) }) await writeFile(path.join(dir, "scanimage"), fakeScanner) await chmod(path.join(dir, "scanimage"), 0o755) process.env.PATH = `${dir}:${process.env.PATH}` process.env.SCAN_TEST_CALL = path.join(dir, "call.json") const config: AgentConfig = { fedeoUrl: "https://example.invalid", agentToken: "test", pollSeconds: 5, workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test", scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex", scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document", scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng", postprocessPython: "python3", postprocessStrict: false, } const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } } return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] } } test("scanner discovery waits for network probes beyond the former ten-second limit", async (t) => { await setup(t) process.env.SCAN_TEST_DISCOVERY_DELAY = "10.2" assert.deepEqual(await listScanners(), ["fujitsu:ScanSnap iX1600:test", "airscan:test"]) }) test("failed scanner discovery does not advertise devices from an unsuccessful command", async (t) => { await setup(t) process.env.SCAN_TEST_EXIT = "1" assert.deepEqual(await listScanners(), []) }) test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => { const { config, job, args } = await setup(t) process.env.SCAN_TEST_EMPTY_FINAL = "1" const progress: string[] = [] const result = await runScan(config, job, async (message) => { progress.push(message) }) assert.equal(result.pageCount, 4) assert.equal(result.mimeType, "application/pdf") assert.equal((await readFile(result.path)).subarray(0, 5).toString(), "%PDF-") assert.deepEqual(result.warnings, []) const call = await args() assert.ok(call.includes("--batch=page-%06d.png")) assert.ok(!call.includes("--output-file")) assert.ok(!call.includes("--batch-double")) assert.equal(call[call.indexOf("--source") + 1], "ADF Duplex") assert.equal(call[call.indexOf("--page-height") + 1], "297") assert.equal(call[call.indexOf("-y") + 1], "297") assert.ok(progress.at(-1)?.startsWith("4 Seiten")) }) test("empty ADF reports missing paper instead of creating an empty PDF", async (t) => { const { config, job } = await setup(t) process.env.SCAN_TEST_PAGES = "0" process.env.SCAN_TEST_EMPTY_FINAL = "1" await assert.rejects(runScan(config, job), /Keine Seiten gescannt/) }) test("paper jam after partial capture fails and keeps raw pages for recovery", async (t) => { const { config, job } = await setup(t) process.env.SCAN_TEST_EXIT = "6" await assert.rejects(runScan(config, job), /simulated scanner status.*Rohscans/) const [jobDir] = await readdir(config.workDir) assert.equal((await readdir(path.join(config.workDir, jobDir))).filter((name) => name.endsWith(".png")).length, 4) }) test("odd duplex page count cannot silently lose a back side", async (t) => { const { config, job } = await setup(t) process.env.SCAN_TEST_PAGES = "3" await assert.rejects(runScan(config, job), /Rückseite/) }) test("ADF simplex keeps odd page counts and scanimage success is accepted", async (t) => { const { config, job } = await setup(t) process.env.SCAN_TEST_PAGES = "3" process.env.SCAN_TEST_EXIT = "0" job.settings!.source = "ADF Front" assert.equal((await runScan(config, job)).pageCount, 3) }) test("corrupt or empty interior pages fail instead of disappearing from a successful PDF", async (t) => { const { config, job } = await setup(t) process.env.SCAN_TEST_CORRUPT = "1" await assert.rejects(runScan(config, job), /cannot identify image file/) delete process.env.SCAN_TEST_CORRUPT process.env.SCAN_TEST_EMPTY_MIDDLE = "1" await assert.rejects(runScan(config, job), /Unvollständige Scan-Seite/) }) test("non-PDF feeder output and invalid resolution are rejected before feeding paper", async (t) => { const { config, job, args } = await setup(t) job.settings!.format = "png" await assert.rejects(runScan(config, job), /PDF/) delete job.settings!.format job.settings!.resolution = -300 await assert.rejects(runScan(config, job), /Auflösung/) await assert.rejects(args(), /ENOENT/) }) test("missing image runtime is detected before feeding paper", async (t) => { const { config, job, args } = await setup(t) config.postprocessPython = "/not-installed/python" await assert.rejects(runScan(config, job), /Python\/Pillow/) await assert.rejects(args(), /ENOENT/) }) test("missing requested OCR language fails before the scanner consumes paper", async (t) => { const { config, job, dir, args } = await setup(t) const tesseract = path.join(dir, "tesseract") await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n") await chmod(tesseract, 0o755) job.settings!.ocr = true job.settings!.ocrLanguage = "deu+eng" await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/) await assert.rejects(args(), /ENOENT/) await assert.rejects(readdir(config.workDir), /ENOENT/) }) test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => { const { config, job, args } = await setup(t) job.settings = { source: "Flatbed", format: "png" } job.requestedFilename = "../../outside.pdf" const first = await runScan(config, job) const second = await runScan(config, job) assert.equal(first.filename, "outside.png") assert.equal(first.pageCount, 1) assert.equal(first.mimeType, "image/png") assert.ok(first.path.startsWith(`${config.workDir}${path.sep}`)) assert.notEqual(path.dirname(first.path), path.dirname(second.path)) assert.ok((await args()).includes("--output-file")) assert.ok(!(await args()).some((arg) => arg.startsWith("--batch"))) }) test("a process terminated by a signal is never reported as a successful scan", async () => { await assert.rejects(runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"]), /SIGTERM/) }) test("a scan timeout terminates the child and reports failure", async () => { await assert.rejects(runCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 50 }), /beendet/) }) test("command discovery treats executable paths as data", async () => { assert.equal(await commandExists("python3; exit 0"), false) })