test: run tests against docker image (#664)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Emrik Östling
2026-10-06 17:45:45 +02:00
committed by GitHub
co-authored by cubic-dev-ai[bot] autofix-ci[bot]
parent 7fa8c9819e
commit 36cee01dde
7 changed files with 97 additions and 165 deletions
+15
View File
@@ -74,6 +74,21 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and load for testing
if: matrix.platform == 'linux/amd64'
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: convertx:test
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Run integration tests
if: matrix.platform == 'linux/amd64'
run: docker run --rm -v ./tests:/app/tests -v ./src:/app/src -v ./package.json:/app/package.json convertx:test bun test tests/integration
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
+2 -2
View File
@@ -29,5 +29,5 @@ jobs:
- name: Install dependencies
run: bun install
- name: Run tests
run: bun test
- name: Run unit tests
run: bun run test
+2
View File
@@ -2,6 +2,8 @@
services:
convertx:
image: convertx:test
pull_policy: build
build:
context: .
# dockerfile: Debian.Dockerfile
+4 -1
View File
@@ -8,13 +8,16 @@
"format:eslint": "eslint --fix .",
"format:prettier": "prettier --write .",
"build:js": "tsc",
"build:docker": "docker compose build",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:js",
"lint": "npm-run-all 'lint:*'",
"lint:tsc": "tsc --noEmit",
"lint:knip": "knip",
"lint:eslint": "eslint .",
"lint:prettier": "prettier --check .",
"lint:xss": "xss-scan"
"lint:xss": "xss-scan",
"test": "bun test --path-ignore-patterns=tests/integration",
"test:integration": "docker run --rm -v ./tests:/app/tests -v ./src:/app/src -v ./package.json:/app/package.json convertx:test bun test tests/integration"
},
"dependencies": {
"@elysiajs/html": "^1.4.2",
+19 -16
View File
@@ -23,7 +23,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`pandoc v${stdout.match(/pandoc ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -34,7 +34,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`FFmpeg v${stdout.match(/version ([\w+.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -45,7 +45,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`Vips v${stdout.match(/vips-([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -56,7 +56,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]?.replace("Version: ", ""));
console.log(`ImageMagick v${stdout.match(/ImageMagick ([\d.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -67,7 +67,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`GraphicsMagick v${stdout.match(/GraphicsMagick ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -78,7 +78,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`Inkscape v${stdout.match(/Inkscape ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -89,7 +89,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`djxl v${stdout.match(/(?:v)?([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -100,7 +100,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(`dasel ${stdout.split("\n")[0]}`);
const ver = stdout.replace("dasel", "").trim().split(/\s+/)[0] || "unknown";
console.log(`dasel ${ver}`);
}
});
@@ -111,7 +112,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`XeTeX v${stdout.match(/XeTeX ([\d.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -122,7 +123,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(`resvg v${stdout.split("\n")[0]}`);
console.log(`resvg v${(stdout.split("\n")[0] ?? "").trim().replace(/^v/, "")}`);
}
});
@@ -133,8 +134,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
const firstLines = stdout.split("\n");
console.log(`assimp ${firstLines[5] || firstLines[0] || ""}`);
const match = stdout.match(/Version ([\d.]+)/i);
console.log(`assimp v${match?.[1] || "unknown"}`);
}
});
@@ -145,7 +146,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`ebook-convert v${stdout.match(/calibre ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -167,7 +168,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
const ver = stdout.match(/potrace ([\d.]+)/)?.[1]?.replace(/\.$/, "") || "unknown";
console.log(`potrace v${ver}`);
}
});
@@ -178,7 +180,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`LibreOffice v${stdout.match(/LibreOffice ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -204,7 +206,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(`${stdout.split("\n")[0]}`);
const ver = stdout.match(/([\d.]+)/)?.[1] || "unknown";
console.log(`markitdown v${ver}`);
}
});
-146
View File
@@ -1,146 +0,0 @@
import { afterEach, expect, mock, spyOn, test } from "bun:test";
import { exec } from "node:child_process";
import { readFile } from "node:fs";
// mocks have to be defined before importing the module under test,
// otherwise the real modules will be loaded first and the mocks won't take effect.
mock.module("node:child_process", () => ({
// The mock checks the environment variable MOCK_EXEC_ERROR at call-time so
// individual tests can trigger error paths by setting that env before
// importing the module under test.
exec: mock((cmd: string, cb: (error: Error | null, stdout: string) => void) => {
const shouldError = (process.env.MOCK_EXEC_ERROR || "")
.split(",")
.some((p) => p && cmd.includes(p));
if (shouldError) {
cb(new Error(`${cmd} not found`), "");
return;
}
// resvg, bun, heif-info, dasel, and the perl one-liner (msgconvert) print just
// the bare version number — the source code itself prepends the tool name as a label.
if (
cmd.startsWith("resvg") ||
cmd.startsWith("bun") ||
cmd.startsWith("heif-info") ||
cmd.startsWith("dasel") ||
cmd.startsWith("perl")
) {
cb(null, "1.0.0\n");
return;
}
// assimp's real output is multi-line; the source reads line index 5.
if (cmd.startsWith("assimp")) {
cb(null, "l1\nl2\nl3\nl4\nl5\nVersion 1.0.0 (GIT commit abc123)\n");
return;
}
// magick prefixes its version line with "Version: ".
if (cmd.startsWith("magick")) {
cb(null, "Version: ImageMagick v1.0.0\n");
return;
}
cb(null, `${cmd} v1.0.0\n`);
}),
}));
mock.module("node:fs", () => ({
readFile: mock(
(path: string, encoding: string, cb: (error: Error | null, data: string) => void) => {
cb(null, 'PRETTY_NAME="Ubuntu 22.04 LTS"\n');
},
),
}));
mock.module("../../package.json", () => ({
version: "1.0.0-test",
}));
// We import the module in each test after the spies and the desired NODE_ENV were set
// so that the top level execution is under control of each test
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
delete process.env.NODE_ENV;
delete process.env.MOCK_EXEC_ERROR;
});
test("prints only the version and terminates itself immediately when NODE_ENV is not production", async () => {
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import the module after the spies were set so that the initial console.log within the module
// gets recognized by the spy
await import("../../src/helpers/printVersions?test=" + Math.random());
// empty the queue to make sure all callbacks have been processed
await new Promise((resolve) => setImmediate(resolve));
expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test");
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
expect(readFile).not.toHaveBeenCalled();
});
test("prints system information and tool versions in production mode", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import module under test after setting NODE_ENV and activating the spies
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test");
expect(consoleLogSpy).toHaveBeenCalledWith("Ubuntu 22.04 LTS");
expect(readFile).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenCalledTimes(18); // Corresponds exactly to the number of exec calls in the source code
});
test("logs error paths when tools are missing", async () => {
process.env.NODE_ENV = "production";
// Trigger a few error paths ("perl" is the msgconvert version lookup)
process.env.MOCK_EXEC_ERROR = "pandoc,resvg,bun,perl";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
expect(consoleErrorSpy).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith("Pandoc is not installed.");
expect(consoleErrorSpy).toHaveBeenCalledWith("resvg is not installed");
expect(consoleErrorSpy).toHaveBeenCalledWith("Bun is not installed. wait what");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"msgconvert (libemail-outlook-message-perl) is not installed",
);
});
test("processes output parsing correctly and logs no errors in the success case", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import module under test after setting NODE_ENV and activating the spies
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
// testing some specific outputs
expect(consoleLogSpy).toHaveBeenCalledWith("pandoc -v v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("ffmpeg -version v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("resvg v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("Bun v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("dasel 1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("msgconvert v1.0.0");
// make sure that error paths have not been triggered
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
let originalNodeEnv: string | undefined;
beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV;
});
afterEach(() => {
if (consoleLogSpy) consoleLogSpy.mockRestore();
if (consoleErrorSpy) consoleErrorSpy.mockRestore();
process.env.NODE_ENV = originalNodeEnv;
});
test("prints system information and tool versions in production mode without errors", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
await import("../../src/helpers/printVersions?test=" + Math.random());
const printVersionsContent = await Bun.file("src/helpers/printVersions.ts").text();
const expectedLogsCount = (printVersionsContent.match(/console\.log/g) || []).length;
// Poll every 50ms (up to 3 seconds) until all expected versions are logged
for (let i = 0; i < 60; i++) {
if (consoleLogSpy.mock.calls.length >= expectedLogsCount) break;
await new Promise((resolve) => setTimeout(resolve, 50));
}
expect(consoleErrorSpy).not.toHaveBeenCalled();
// We expect exactly one log per console.log statement in the file
expect(consoleLogSpy.mock.calls.length).toBe(expectedLogsCount);
// Verify the standard format "ToolName v1.2.3" for all tools (except ConvertX version and OS)
// ConvertX vX.X.X is the first call, OS is the second call.
// Actually, because callbacks are async, we should just check if it matches the pattern or is OS string
for (const call of consoleLogSpy.mock.calls) {
const msg = call[0];
// Ignore the OS string which doesn't follow the pattern
if (msg.includes("Linux") || msg.includes("Debian") || msg.includes("Ubuntu")) continue;
// Dasel on Debian Sid prints "development" instead of a numerical version
if (msg === "dasel development") continue;
// The pattern is "Name vVersion" where Version must start with a digit
// e.g., "FFmpeg v8.1.2-2+b3", "potrace v1.16"
expect(msg).toMatch(/^[a-zA-Z0-9_-]+\s+v\d+[a-zA-Z0-9+.-]*$/);
expect(msg).not.toMatch(/unknown/i);
}
}, 10000);