diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e56d7c1..9158d73 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.github/workflows/run-bun-test.yml b/.github/workflows/run-bun-test.yml index dd12070..debd528 100644 --- a/.github/workflows/run-bun-test.yml +++ b/.github/workflows/run-bun-test.yml @@ -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 diff --git a/compose.yaml b/compose.yaml index 1bb0d99..89c7bb6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,6 +2,8 @@ services: convertx: + image: convertx:test + pull_policy: build build: context: . # dockerfile: Debian.Dockerfile diff --git a/package.json b/package.json index 0ad46b2..43a37f0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/helpers/printVersions.ts b/src/helpers/printVersions.ts index c17ef9f..57b5bf0 100644 --- a/src/helpers/printVersions.ts +++ b/src/helpers/printVersions.ts @@ -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}`); } }); diff --git a/tests/helpers/printVersions.test.ts b/tests/helpers/printVersions.test.ts deleted file mode 100644 index 28da117..0000000 --- a/tests/helpers/printVersions.test.ts +++ /dev/null @@ -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; -let consoleErrorSpy: ReturnType; - -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(); -}); diff --git a/tests/integration/printVersions.test.ts b/tests/integration/printVersions.test.ts new file mode 100644 index 0000000..37729f5 --- /dev/null +++ b/tests/integration/printVersions.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; + +let consoleLogSpy: ReturnType; +let consoleErrorSpy: ReturnType; +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);