172 lines
8.0 KiB
TypeScript
172 lines
8.0 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",
|
|
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("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)
|
|
})
|