Files
FEDEO/agents/fedeo-device-agent/tests/scan.test.ts

185 lines
8.7 KiB
TypeScript

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)
})